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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorClement Ho <ClemMakesApps@gmail.com>2017-06-09 22:52:37 +0300
committerClement Ho <ClemMakesApps@gmail.com>2017-06-09 22:52:37 +0300
commitf08a8ae18f74ad086695d62ff78ada2796e65829 (patch)
treef65d9be5c66eb0d1ad8ceb1c13eb5c0da6bc2496 /app
parent66bbf30ed8bb006d9a968693fef266c86ec2325f (diff)
parentabc61f260074663e5711d3814d9b7d301d07a259 (diff)
Merge commit 'abc61f260074663e5711d3814d9b7d301d07a259' into 9-3-stable
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/i2p-step.svg4
-rw-r--r--app/assets/javascripts/activities.js5
-rw-r--r--app/assets/javascripts/api.js229
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji.js1
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js3
-rw-r--r--app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js32
-rw-r--r--app/assets/javascripts/blob/balsamiq_viewer.js24
-rw-r--r--app/assets/javascripts/blob/file_template_selector.js3
-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.js2
-rw-r--r--app/assets/javascripts/blob/viewer/index.js98
-rw-r--r--app/assets/javascripts/boards/boards_bundle.js38
-rw-r--r--app/assets/javascripts/boards/components/board.js27
-rw-r--r--app/assets/javascripts/boards/components/board_card.js2
-rw-r--r--app/assets/javascripts/boards/components/board_list.js32
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.js2
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js19
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.js28
-rw-r--r--app/assets/javascripts/boards/components/modal/filters.js1
-rw-r--r--app/assets/javascripts/boards/components/modal/footer.js6
-rw-r--r--app/assets/javascripts/boards/components/modal/header.js3
-rw-r--r--app/assets/javascripts/boards/components/modal/index.js13
-rw-r--r--app/assets/javascripts/boards/components/modal/lists_dropdown.js2
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js8
-rw-r--r--app/assets/javascripts/boards/models/list.js12
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js9
-rw-r--r--app/assets/javascripts/branches/branches_delete_modal.js36
-rw-r--r--app/assets/javascripts/build.js336
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.js44
-rw-r--r--app/assets/javascripts/commits.js37
-rw-r--r--app/assets/javascripts/copy_as_gfm.js130
-rw-r--r--app/assets/javascripts/create_label.js2
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_code_component.js7
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_issue_component.js8
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_plan_component.js9
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_production_component.js8
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_review_component.js8
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_staging_component.js11
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_test_component.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js22
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_store.js6
-rw-r--r--app/assets/javascripts/cycle_analytics/default_event_objects.js2
-rw-r--r--app/assets/javascripts/deploy_keys/components/action_btn.vue13
-rw-r--r--app/assets/javascripts/deploy_keys/components/app.vue30
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue32
-rw-r--r--app/assets/javascripts/deploy_keys/components/keys_panel.vue14
-rw-r--r--app/assets/javascripts/diff.js2
-rw-r--r--app/assets/javascripts/diff_notes/components/diff_note_avatars.js28
-rw-r--r--app/assets/javascripts/diff_notes/components/jump_to_discussion.js7
-rw-r--r--app/assets/javascripts/diff_notes/diff_notes_bundle.js25
-rw-r--r--app/assets/javascripts/diff_notes/services/resolve.js6
-rw-r--r--app/assets/javascripts/dispatcher.js64
-rw-r--r--app/assets/javascripts/droplab/drop_down.js128
-rw-r--r--app/assets/javascripts/droplab/drop_lab.js126
-rw-r--r--app/assets/javascripts/droplab/hook.js29
-rw-r--r--app/assets/javascripts/droplab/hook_button.js59
-rw-r--r--app/assets/javascripts/droplab/hook_input.js68
-rw-r--r--app/assets/javascripts/droplab/keyboard.js2
-rw-r--r--app/assets/javascripts/droplab/plugins/ajax.js34
-rw-r--r--app/assets/javascripts/droplab/plugins/ajax_filter.js48
-rw-r--r--app/assets/javascripts/dropzone_input.js260
-rw-r--r--app/assets/javascripts/environments/components/environment.vue124
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue10
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue115
-rw-r--r--app/assets/javascripts/environments/components/environment_monitoring.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue12
-rw-r--r--app/assets/javascripts/environments/components/environment_stop.vue12
-rw-r--r--app/assets/javascripts/environments/components/environment_terminal_button.vue2
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue113
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.vue123
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js17
-rw-r--r--app/assets/javascripts/environments/services/environments_service.js3
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js6
-rw-r--r--app/assets/javascripts/files_comment_button.js5
-rw-r--r--app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js6
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js32
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js5
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_user.js16
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_utils.js11
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_bundle.js20
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js10
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js136
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_token_keys.js8
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_tokenizer.js5
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js97
-rw-r--r--app/assets/javascripts/filtered_search/recent_searches_root.js1
-rw-r--r--app/assets/javascripts/filtered_search/stores/recent_searches_store.js4
-rw-r--r--app/assets/javascripts/flash.js34
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js495
-rw-r--r--app/assets/javascripts/gl_dropdown.js31
-rw-r--r--app/assets/javascripts/gl_field_errors.js12
-rw-r--r--app/assets/javascripts/gl_form.js15
-rw-r--r--app/assets/javascripts/graphs/stat_graph_contributors_graph.js3
-rw-r--r--app/assets/javascripts/group_name.js6
-rw-r--r--app/assets/javascripts/groups_select.js2
-rw-r--r--app/assets/javascripts/importer_status.js2
-rw-r--r--app/assets/javascripts/integrations/index.js7
-rw-r--r--app/assets/javascripts/integrations/integration_settings_form.js123
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js159
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar.js165
-rw-r--r--app/assets/javascripts/issuable_context.js3
-rw-r--r--app/assets/javascripts/issuable_form.js16
-rw-r--r--app/assets/javascripts/issuable_index.js (renamed from app/assets/javascripts/issuable.js)81
-rw-r--r--app/assets/javascripts/issue.js12
-rw-r--r--app/assets/javascripts/issue_show/actions/tasks.js27
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue273
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue96
-rw-r--r--app/assets/javascripts/issue_show/components/edit_actions.vue79
-rw-r--r--app/assets/javascripts/issue_show/components/edited.vue1
-rw-r--r--app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue23
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description.vue54
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description_template.vue111
-rw-r--r--app/assets/javascripts/issue_show/components/fields/project_move.vue83
-rw-r--r--app/assets/javascripts/issue_show/components/fields/title.vue31
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue104
-rw-r--r--app/assets/javascripts/issue_show/components/locked_warning.vue20
-rw-r--r--app/assets/javascripts/issue_show/components/title.vue53
-rw-r--r--app/assets/javascripts/issue_show/event_hub.js3
-rw-r--r--app/assets/javascripts/issue_show/index.js65
-rw-r--r--app/assets/javascripts/issue_show/mixins/animate.js13
-rw-r--r--app/assets/javascripts/issue_show/mixins/update.js10
-rw-r--r--app/assets/javascripts/issue_show/services/index.js27
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js52
-rw-r--r--app/assets/javascripts/issues_bulk_assignment.js166
-rw-r--r--app/assets/javascripts/labels.js6
-rw-r--r--app/assets/javascripts/labels_select.js15
-rw-r--r--app/assets/javascripts/layout_nav.js10
-rw-r--r--app/assets/javascripts/lib/utils/ajax_cache.js70
-rw-r--r--app/assets/javascripts/lib/utils/cache.js19
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js11
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js74
-rw-r--r--app/assets/javascripts/lib/utils/http_status.js4
-rw-r--r--app/assets/javascripts/lib/utils/notify.js85
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js10
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js5
-rw-r--r--app/assets/javascripts/lib/utils/type_utility.js17
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js3
-rw-r--r--app/assets/javascripts/lib/utils/users_cache.js28
-rw-r--r--app/assets/javascripts/line_highlighter.js10
-rw-r--r--app/assets/javascripts/locale/de/app.js2
-rw-r--r--app/assets/javascripts/locale/en/app.js2
-rw-r--r--app/assets/javascripts/locale/es/app.js2
-rw-r--r--app/assets/javascripts/locale/zh_CN/app.js1
-rw-r--r--app/assets/javascripts/locale/zh_HK/app.js1
-rw-r--r--app/assets/javascripts/locale/zh_TW/app.js1
-rw-r--r--app/assets/javascripts/main.js8
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js15
-rw-r--r--app/assets/javascripts/merge_request.js10
-rw-r--r--app/assets/javascripts/merge_request_tabs.js12
-rw-r--r--app/assets/javascripts/merge_request_widget.js305
-rw-r--r--app/assets/javascripts/milestone_select.js37
-rw-r--r--app/assets/javascripts/namespace_select.js6
-rw-r--r--app/assets/javascripts/new_branch_form.js7
-rw-r--r--app/assets/javascripts/new_commit_form.js4
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue24
-rw-r--r--app/assets/javascripts/notes.js358
-rw-r--r--app/assets/javascripts/notifications_form.js4
-rw-r--r--app/assets/javascripts/pager.js9
-rw-r--r--app/assets/javascripts/pipelines/components/async_button.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue91
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue97
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.js56
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.vue65
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_actions.js10
-rw-r--r--app/assets/javascripts/pipelines/components/stage.vue23
-rw-r--r--app/assets/javascripts/pipelines/graph_bundle.js10
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js70
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_mediatior.js59
-rw-r--r--app/assets/javascripts/pipelines/pipelines.js52
-rw-r--r--app/assets/javascripts/pipelines/services/pipeline_service.js5
-rw-r--r--app/assets/javascripts/pipelines/services/pipelines_service.js2
-rw-r--r--app/assets/javascripts/pipelines/stores/pipeline_store.js6
-rw-r--r--app/assets/javascripts/profile/profile_bundle.js4
-rw-r--r--app/assets/javascripts/project_edit.js9
-rw-r--r--app/assets/javascripts/project_find_file.js10
-rw-r--r--app/assets/javascripts/project_new.js67
-rw-r--r--app/assets/javascripts/project_select.js11
-rw-r--r--app/assets/javascripts/protected_branches/protected_branches_bundle.js10
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_dropdown.js4
-rw-r--r--app/assets/javascripts/raven/raven_config.js3
-rw-r--r--app/assets/javascripts/right_sidebar.js4
-rw-r--r--app/assets/javascripts/search.js36
-rw-r--r--app/assets/javascripts/settings_panels.js27
-rw-r--r--app/assets/javascripts/shortcuts.js4
-rw-r--r--app/assets/javascripts/shortcuts_blob.js6
-rw-r--r--app/assets/javascripts/shortcuts_find_file.js2
-rw-r--r--app/assets/javascripts/shortcuts_issuable.js16
-rw-r--r--app/assets/javascripts/shortcuts_navigation.js4
-rw-r--r--app/assets/javascripts/shortcuts_network.js2
-rw-r--r--app/assets/javascripts/single_file_diff.js6
-rw-r--r--app/assets/javascripts/task_list.js3
-rw-r--r--app/assets/javascripts/templates/issuable_template_selector.js2
-rw-r--r--app/assets/javascripts/terminal/terminal_bundle.js12
-rw-r--r--app/assets/javascripts/todos.js3
-rw-r--r--app/assets/javascripts/u2f/authenticate.js16
-rw-r--r--app/assets/javascripts/u2f/error.js4
-rw-r--r--app/assets/javascripts/u2f/register.js18
-rw-r--r--app/assets/javascripts/user_callout.js11
-rw-r--r--app/assets/javascripts/users/calendar.js4
-rw-r--r--app/assets/javascripts/users/users_bundle.js2
-rw-r--r--app/assets/javascripts/users_select.js1245
-rw-r--r--app/assets/javascripts/version_check_image.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js44
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js47
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/dependencies.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js5
-rw-r--r--app/assets/javascripts/vue_shared/ci_action_icons.js31
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.js32
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue133
-rw-r--r--app/assets/javascripts/vue_shared/components/loading_icon.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue107
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue113
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue58
-rw-r--r--app/assets/javascripts/vue_shared/components/pipelines_table_row.js19
-rw-r--r--app/assets/javascripts/vue_shared/components/table_pagination.vue (renamed from app/assets/javascripts/vue_shared/components/table_pagination.js)38
-rw-r--r--app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue30
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue86
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue80
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue45
-rw-r--r--app/assets/javascripts/vue_shared/mixins/timeago.js18
-rw-r--r--app/assets/javascripts/vue_shared/mixins/tooltip.js4
-rw-r--r--app/assets/javascripts/vue_shared/vue_resource_interceptor.js2
-rw-r--r--app/assets/javascripts/wikis.js4
-rw-r--r--app/assets/javascripts/zen_mode.js11
-rw-r--r--app/assets/stylesheets/framework.scss2
-rw-r--r--app/assets/stylesheets/framework/avatar.scss2
-rw-r--r--app/assets/stylesheets/framework/awards.scss7
-rw-r--r--app/assets/stylesheets/framework/blocks.scss22
-rw-r--r--app/assets/stylesheets/framework/common.scss6
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss16
-rw-r--r--app/assets/stylesheets/framework/emojis.scss1
-rw-r--r--app/assets/stylesheets/framework/files.scss20
-rw-r--r--app/assets/stylesheets/framework/filters.scss91
-rw-r--r--app/assets/stylesheets/framework/flash.scss20
-rw-r--r--app/assets/stylesheets/framework/gfm.scss4
-rw-r--r--app/assets/stylesheets/framework/header.scss16
-rw-r--r--app/assets/stylesheets/framework/icons.scss4
-rw-r--r--app/assets/stylesheets/framework/layout.scss4
-rw-r--r--app/assets/stylesheets/framework/lists.scss2
-rw-r--r--app/assets/stylesheets/framework/mobile.scss4
-rw-r--r--app/assets/stylesheets/framework/nav.scss34
-rw-r--r--app/assets/stylesheets/framework/notes.scss14
-rw-r--r--app/assets/stylesheets/framework/responsive-tables.scss91
-rw-r--r--app/assets/stylesheets/framework/selects.scss1
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss42
-rw-r--r--app/assets/stylesheets/framework/timeline.scss33
-rw-r--r--app/assets/stylesheets/framework/typography.scss50
-rw-r--r--app/assets/stylesheets/framework/variables.scss13
-rw-r--r--app/assets/stylesheets/framework/wells.scss6
-rw-r--r--app/assets/stylesheets/highlight/dark.scss5
-rw-r--r--app/assets/stylesheets/highlight/monokai.scss5
-rw-r--r--app/assets/stylesheets/highlight/solarized_dark.scss5
-rw-r--r--app/assets/stylesheets/highlight/solarized_light.scss5
-rw-r--r--app/assets/stylesheets/highlight/white.scss5
-rw-r--r--app/assets/stylesheets/pages/boards.scss85
-rw-r--r--app/assets/stylesheets/pages/builds.scss227
-rw-r--r--app/assets/stylesheets/pages/ci_projects.scss1
-rw-r--r--app/assets/stylesheets/pages/commits.scss14
-rw-r--r--app/assets/stylesheets/pages/convdev_index.scss255
-rw-r--r--app/assets/stylesheets/pages/cycle_analytics.scss4
-rw-r--r--app/assets/stylesheets/pages/detail_page.scss6
-rw-r--r--app/assets/stylesheets/pages/diff.scss39
-rw-r--r--app/assets/stylesheets/pages/environments.scss99
-rw-r--r--app/assets/stylesheets/pages/issuable.scss53
-rw-r--r--app/assets/stylesheets/pages/issues.scss11
-rw-r--r--app/assets/stylesheets/pages/members.scss2
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss94
-rw-r--r--app/assets/stylesheets/pages/note_form.scss79
-rw-r--r--app/assets/stylesheets/pages/notes.scss187
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss35
-rw-r--r--app/assets/stylesheets/pages/profile.scss25
-rw-r--r--app/assets/stylesheets/pages/projects.scss89
-rw-r--r--app/assets/stylesheets/pages/settings.scss87
-rw-r--r--app/assets/stylesheets/pages/tree.scss9
-rw-r--r--app/assets/stylesheets/print.scss8
-rw-r--r--app/controllers/admin/application_settings_controller.rb1
-rw-r--r--app/controllers/admin/applications_controller.rb2
-rw-r--r--app/controllers/admin/conversational_development_index_controller.rb5
-rw-r--r--app/controllers/admin/deploy_keys_controller.rb26
-rw-r--r--app/controllers/admin/groups_controller.rb11
-rw-r--r--app/controllers/admin/hook_logs_controller.rb29
-rw-r--r--app/controllers/admin/hooks_controller.rb35
-rw-r--r--app/controllers/admin/identities_controller.rb4
-rw-r--r--app/controllers/admin/impersonations_controller.rb2
-rw-r--r--app/controllers/admin/jobs_controller.rb (renamed from app/controllers/admin/builds_controller.rb)4
-rw-r--r--app/controllers/admin/keys_controller.rb4
-rw-r--r--app/controllers/admin/labels_controller.rb2
-rw-r--r--app/controllers/admin/runner_projects_controller.rb2
-rw-r--r--app/controllers/admin/runners_controller.rb2
-rw-r--r--app/controllers/admin/spam_logs_controller.rb4
-rw-r--r--app/controllers/admin/users_controller.rb4
-rw-r--r--app/controllers/application_controller.rb38
-rw-r--r--app/controllers/autocomplete_controller.rb4
-rw-r--r--app/controllers/concerns/diff_for_path.rb13
-rw-r--r--app/controllers/concerns/hooks_execution.rb15
-rw-r--r--app/controllers/concerns/issuable_actions.rb11
-rw-r--r--app/controllers/concerns/issuable_collections.rb2
-rw-r--r--app/controllers/concerns/lfs_request.rb10
-rw-r--r--app/controllers/concerns/membership_actions.rb7
-rw-r--r--app/controllers/concerns/renders_blob.rb11
-rw-r--r--app/controllers/dashboard/projects_controller.rb3
-rw-r--r--app/controllers/dashboard/todos_controller.rb8
-rw-r--r--app/controllers/dashboard_controller.rb4
-rw-r--r--app/controllers/groups/avatars_controller.rb2
-rw-r--r--app/controllers/groups/labels_controller.rb2
-rw-r--r--app/controllers/groups_controller.rb5
-rw-r--r--app/controllers/health_controller.rb19
-rw-r--r--app/controllers/jwt_controller.rb8
-rw-r--r--app/controllers/metrics_controller.rb21
-rw-r--r--app/controllers/oauth/authorized_applications_controller.rb4
-rw-r--r--app/controllers/profiles/avatars_controller.rb2
-rw-r--r--app/controllers/profiles/chat_names_controller.rb2
-rw-r--r--app/controllers/profiles/emails_controller.rb2
-rw-r--r--app/controllers/profiles/keys_controller.rb2
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb2
-rw-r--r--app/controllers/profiles/preferences_controller.rb2
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb2
-rw-r--r--app/controllers/profiles/u2f_registrations_controller.rb2
-rw-r--r--app/controllers/profiles_controller.rb12
-rw-r--r--app/controllers/projects/artifacts_controller.rb6
-rw-r--r--app/controllers/projects/avatars_controller.rb2
-rw-r--r--app/controllers/projects/blob_controller.rb4
-rw-r--r--app/controllers/projects/boards/lists_controller.rb4
-rw-r--r--app/controllers/projects/branches_controller.rb7
-rw-r--r--app/controllers/projects/build_artifacts_controller.rb55
-rw-r--r--app/controllers/projects/builds_controller.rb122
-rw-r--r--app/controllers/projects/compare_controller.rb6
-rw-r--r--app/controllers/projects/deploy_keys_controller.rb29
-rw-r--r--app/controllers/projects/deployments_controller.rb6
-rw-r--r--app/controllers/projects/environments_controller.rb3
-rw-r--r--app/controllers/projects/git_http_client_controller.rb24
-rw-r--r--app/controllers/projects/git_http_controller.rb75
-rw-r--r--app/controllers/projects/group_links_controller.rb2
-rw-r--r--app/controllers/projects/hook_logs_controller.rb33
-rw-r--r--app/controllers/projects/hooks_controller.rb19
-rw-r--r--app/controllers/projects/imports_controller.rb9
-rw-r--r--app/controllers/projects/issues_controller.rb38
-rw-r--r--app/controllers/projects/jobs_controller.rb142
-rw-r--r--app/controllers/projects/labels_controller.rb4
-rw-r--r--app/controllers/projects/lfs_api_controller.rb4
-rw-r--r--[-rwxr-xr-x]app/controllers/projects/merge_requests_controller.rb9
-rw-r--r--app/controllers/projects/milestones_controller.rb2
-rw-r--r--app/controllers/projects/pages_controller.rb5
-rw-r--r--app/controllers/projects/pages_domains_controller.rb5
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb6
-rw-r--r--app/controllers/projects/pipelines_controller.rb6
-rw-r--r--app/controllers/projects/pipelines_settings_controller.rb2
-rw-r--r--app/controllers/projects/protected_branches_controller.rb4
-rw-r--r--app/controllers/projects/protected_refs_controller.rb6
-rw-r--r--app/controllers/projects/protected_tags_controller.rb2
-rw-r--r--app/controllers/projects/refs_controller.rb2
-rw-r--r--app/controllers/projects/registry/repositories_controller.rb2
-rw-r--r--app/controllers/projects/registry/tags_controller.rb2
-rw-r--r--app/controllers/projects/runner_projects_controller.rb2
-rw-r--r--app/controllers/projects/runners_controller.rb2
-rw-r--r--app/controllers/projects/services_controller.rb39
-rw-r--r--app/controllers/projects/snippets_controller.rb6
-rw-r--r--app/controllers/projects/tags_controller.rb6
-rw-r--r--app/controllers/projects/tree_controller.rb4
-rw-r--r--app/controllers/projects/triggers_controller.rb2
-rw-r--r--app/controllers/projects/variables_controller.rb7
-rw-r--r--app/controllers/projects/wikis_controller.rb7
-rw-r--r--app/controllers/projects_controller.rb32
-rw-r--r--app/controllers/registrations_controller.rb4
-rw-r--r--app/controllers/sessions_controller.rb11
-rw-r--r--app/controllers/sherlock/transactions_controller.rb2
-rw-r--r--app/controllers/snippets_controller.rb14
-rw-r--r--app/controllers/uploads_controller.rb13
-rw-r--r--app/finders/events_finder.rb62
-rw-r--r--app/finders/projects_finder.rb33
-rw-r--r--app/finders/todos_finder.rb2
-rw-r--r--app/finders/users_finder.rb74
-rw-r--r--app/helpers/application_helper.rb24
-rw-r--r--app/helpers/avatars_helper.rb20
-rw-r--r--app/helpers/blob_helper.rb35
-rw-r--r--app/helpers/branches_helper.rb10
-rw-r--r--app/helpers/builds_helper.rb8
-rw-r--r--app/helpers/button_helper.rb11
-rw-r--r--app/helpers/ci_status_helper.rb39
-rw-r--r--app/helpers/commits_helper.rb39
-rw-r--r--app/helpers/conversational_development_index_helper.rb16
-rw-r--r--app/helpers/diff_helper.rb34
-rw-r--r--app/helpers/dropdowns_helper.rb16
-rw-r--r--app/helpers/emails_helper.rb2
-rw-r--r--app/helpers/events_helper.rb13
-rw-r--r--app/helpers/explore_helper.rb2
-rw-r--r--app/helpers/gitlab_routing_helper.rb22
-rw-r--r--app/helpers/icons_helper.rb7
-rw-r--r--app/helpers/issuables_helper.rb45
-rw-r--r--app/helpers/labels_helper.rb5
-rw-r--r--app/helpers/markup_helper.rb2
-rw-r--r--app/helpers/merge_requests_helper.rb2
-rw-r--r--app/helpers/notes_helper.rb14
-rw-r--r--app/helpers/notifications_helper.rb39
-rw-r--r--app/helpers/preferences_helper.rb2
-rw-r--r--app/helpers/profiles_helper.rb7
-rw-r--r--app/helpers/projects_helper.rb96
-rw-r--r--app/helpers/rss_helper.rb2
-rw-r--r--app/helpers/search_helper.rb6
-rw-r--r--app/helpers/selects_helper.rb10
-rw-r--r--app/helpers/sorting_helper.rb2
-rw-r--r--app/helpers/submodule_helper.rb16
-rw-r--r--app/helpers/system_note_helper.rb3
-rw-r--r--app/helpers/todos_helper.rb13
-rw-r--r--app/helpers/visibility_level_helper.rb10
-rw-r--r--app/mailers/base_mailer.rb6
-rw-r--r--app/models/abuse_report.rb3
-rw-r--r--app/models/application_setting.rb29
-rw-r--r--app/models/audit_event.rb2
-rw-r--r--app/models/award_emoji.rb2
-rw-r--r--app/models/blob.rb64
-rw-r--r--app/models/blob_viewer/auxiliary.rb18
-rw-r--r--app/models/blob_viewer/base.rb78
-rw-r--r--app/models/blob_viewer/cartfile.rb15
-rw-r--r--app/models/blob_viewer/changelog.rb16
-rw-r--r--app/models/blob_viewer/client_side.rb6
-rw-r--r--app/models/blob_viewer/composer_json.rb23
-rw-r--r--app/models/blob_viewer/contributing.rb10
-rw-r--r--app/models/blob_viewer/dependency_manager.rb43
-rw-r--r--app/models/blob_viewer/download.rb10
-rw-r--r--app/models/blob_viewer/gemfile.rb15
-rw-r--r--app/models/blob_viewer/gemspec.rb27
-rw-r--r--app/models/blob_viewer/gitlab_ci_yml.rb23
-rw-r--r--app/models/blob_viewer/godeps_json.rb15
-rw-r--r--app/models/blob_viewer/license.rb20
-rw-r--r--app/models/blob_viewer/markup.rb1
-rw-r--r--app/models/blob_viewer/package_json.rb23
-rw-r--r--app/models/blob_viewer/podfile.rb15
-rw-r--r--app/models/blob_viewer/podspec.rb27
-rw-r--r--app/models/blob_viewer/podspec_json.rb9
-rw-r--r--app/models/blob_viewer/readme.rb14
-rw-r--r--app/models/blob_viewer/requirements_txt.rb15
-rw-r--r--app/models/blob_viewer/route_map.rb30
-rw-r--r--app/models/blob_viewer/server_side.rb25
-rw-r--r--app/models/blob_viewer/static.rb14
-rw-r--r--app/models/blob_viewer/text.rb4
-rw-r--r--app/models/blob_viewer/yarn_lock.rb15
-rw-r--r--app/models/board.rb4
-rw-r--r--app/models/ci/build.rb108
-rw-r--r--app/models/ci/legacy_stage.rb64
-rw-r--r--app/models/ci/pipeline.rb66
-rw-r--r--app/models/ci/pipeline_schedule.rb18
-rw-r--r--app/models/ci/stage.rb65
-rw-r--r--app/models/ci/trigger_request.rb2
-rw-r--r--app/models/ci/variable.rb5
-rw-r--r--app/models/commit.rb25
-rw-r--r--app/models/commit_status.rb13
-rw-r--r--app/models/concerns/avatarable.rb18
-rw-r--r--app/models/concerns/discussion_on_diff.rb8
-rw-r--r--app/models/concerns/has_status.rb2
-rw-r--r--app/models/concerns/mentionable.rb16
-rw-r--r--app/models/concerns/mentionable/reference_regexes.rb22
-rw-r--r--app/models/concerns/note_on_diff.rb10
-rw-r--r--app/models/concerns/noteable.rb7
-rw-r--r--app/models/concerns/protected_branch_access.rb22
-rw-r--r--app/models/concerns/protected_ref.rb28
-rw-r--r--app/models/conversational_development_index/card.rb26
-rw-r--r--app/models/conversational_development_index/idea_to_production_step.rb19
-rw-r--r--app/models/conversational_development_index/metric.rb21
-rw-r--r--app/models/deployment.rb16
-rw-r--r--app/models/diff_discussion.rb19
-rw-r--r--app/models/diff_note.rb31
-rw-r--r--app/models/discussion.rb9
-rw-r--r--app/models/environment.rb20
-rw-r--r--app/models/event.rb36
-rw-r--r--app/models/group.rb19
-rw-r--r--app/models/hooks/project_hook.rb2
-rw-r--r--app/models/hooks/service_hook.rb2
-rw-r--r--app/models/hooks/system_hook.rb7
-rw-r--r--app/models/hooks/web_hook.rb46
-rw-r--r--app/models/hooks/web_hook_log.rb13
-rw-r--r--app/models/issue.rb8
-rw-r--r--app/models/key.rb11
-rw-r--r--app/models/label.rb6
-rw-r--r--app/models/label_link.rb2
-rw-r--r--app/models/legacy_diff_note.rb4
-rw-r--r--app/models/lfs_objects_project.rb3
-rw-r--r--app/models/list.rb4
-rw-r--r--app/models/member.rb6
-rw-r--r--app/models/members/group_member.rb4
-rw-r--r--app/models/members/project_member.rb4
-rw-r--r--app/models/merge_request.rb88
-rw-r--r--app/models/merge_request_diff.rb34
-rw-r--r--app/models/milestone.rb2
-rw-r--r--app/models/namespace.rb7
-rw-r--r--app/models/note.rb15
-rw-r--r--app/models/notification_setting.rb2
-rw-r--r--app/models/pages_domain.rb4
-rw-r--r--app/models/personal_access_token.rb13
-rw-r--r--app/models/project.rb89
-rw-r--r--app/models/project_import_data.rb2
-rw-r--r--app/models/project_services/asana_service.rb3
-rw-r--r--app/models/project_services/assembla_service.rb2
-rw-r--r--app/models/project_services/bamboo_service.rb6
-rw-r--r--app/models/project_services/buildkite_service.rb4
-rw-r--r--app/models/project_services/campfire_service.rb4
-rw-r--r--app/models/project_services/chat_message/pipeline_message.rb6
-rw-r--r--app/models/project_services/chat_message/push_message.rb4
-rw-r--r--app/models/project_services/chat_notification_service.rb8
-rw-r--r--app/models/project_services/custom_issue_tracker_service.rb6
-rw-r--r--app/models/project_services/deployment_service.rb4
-rw-r--r--app/models/project_services/drone_ci_service.rb4
-rw-r--r--app/models/project_services/emails_on_push_service.rb2
-rw-r--r--app/models/project_services/external_wiki_service.rb2
-rw-r--r--app/models/project_services/flowdock_service.rb4
-rw-r--r--app/models/project_services/gemnasium_service.rb4
-rw-r--r--app/models/project_services/hipchat_service.rb4
-rw-r--r--app/models/project_services/irker_service.rb4
-rw-r--r--app/models/project_services/issue_tracker_service.rb8
-rw-r--r--app/models/project_services/jira_service.rb64
-rw-r--r--app/models/project_services/kubernetes_service.rb26
-rw-r--r--app/models/project_services/microsoft_teams_service.rb2
-rw-r--r--app/models/project_services/mock_ci_service.rb7
-rw-r--r--app/models/project_services/mock_monitoring_service.rb4
-rw-r--r--app/models/project_services/monitoring_service.rb7
-rw-r--r--app/models/project_services/pipelines_email_service.rb5
-rw-r--r--app/models/project_services/pivotaltracker_service.rb3
-rw-r--r--app/models/project_services/prometheus_service.rb39
-rw-r--r--app/models/project_services/pushover_service.rb8
-rw-r--r--app/models/project_services/teamcity_service.rb8
-rw-r--r--app/models/project_team.rb9
-rw-r--r--app/models/project_wiki.rb7
-rw-r--r--app/models/protected_branch.rb9
-rw-r--r--app/models/protected_branch/merge_access_level.rb10
-rw-r--r--app/models/protected_branch/push_access_level.rb18
-rw-r--r--app/models/protected_tag.rb6
-rw-r--r--app/models/readme_blob.rb13
-rw-r--r--app/models/redirect_route.rb2
-rw-r--r--app/models/repository.rb51
-rw-r--r--app/models/route.rb4
-rw-r--r--app/models/sent_notification.rb6
-rw-r--r--app/models/service.rb6
-rw-r--r--app/models/snippet.rb1
-rw-r--r--app/models/spam_log.rb3
-rw-r--r--app/models/subscription.rb2
-rw-r--r--app/models/system_note_metadata.rb1
-rw-r--r--app/models/todo.rb2
-rw-r--r--app/models/tree.rb5
-rw-r--r--app/models/upload.rb2
-rw-r--r--app/models/user.rb77
-rw-r--r--app/models/user_agent_detail.rb2
-rw-r--r--app/policies/ci/build_policy.rb2
-rw-r--r--app/policies/deploy_key_policy.rb11
-rw-r--r--app/policies/group_policy.rb17
-rw-r--r--app/policies/project_policy.rb6
-rw-r--r--app/presenters/conversational_development_index/metric_presenter.rb144
-rw-r--r--app/presenters/projects/settings/deploy_keys_presenter.rb14
-rw-r--r--app/serializers/analytics_build_entity.rb2
-rw-r--r--app/serializers/build_action_entity.rb2
-rw-r--r--app/serializers/build_artifact_entity.rb39
-rw-r--r--app/serializers/build_details_entity.rb50
-rw-r--r--app/serializers/build_entity.rb8
-rw-r--r--app/serializers/deploy_key_entity.rb7
-rw-r--r--app/serializers/entity_date_helper.rb2
-rw-r--r--app/serializers/issue_entity.rb6
-rw-r--r--app/serializers/merge_request_entity.rb11
-rw-r--r--app/serializers/pipeline_details_entity.rb7
-rw-r--r--app/serializers/pipeline_entity.rb27
-rw-r--r--app/serializers/pipeline_serializer.rb9
-rw-r--r--app/serializers/request_aware_entity.rb1
-rw-r--r--app/serializers/runner_entity.rb18
-rw-r--r--app/serializers/user_entity.rb5
-rw-r--r--app/services/akismet_service.rb2
-rw-r--r--app/services/audit_event_service.rb2
-rw-r--r--app/services/boards/create_service.rb1
-rw-r--r--app/services/boards/issues/list_service.rb2
-rw-r--r--app/services/boards/issues/move_service.rb2
-rw-r--r--app/services/boards/lists/list_service.rb2
-rw-r--r--app/services/ci/create_pipeline_builds_service.rb51
-rw-r--r--app/services/ci/create_pipeline_service.rb18
-rw-r--r--app/services/ci/create_pipeline_stages_service.rb20
-rw-r--r--app/services/ci/create_trigger_request_service.rb2
-rw-r--r--app/services/ci/retry_build_service.rb2
-rw-r--r--app/services/compare_service.rb6
-rw-r--r--app/services/create_deployment_service.rb76
-rw-r--r--app/services/delete_branch_service.rb16
-rw-r--r--app/services/discussions/update_diff_position_service.rb41
-rw-r--r--app/services/git_push_service.rb10
-rw-r--r--app/services/git_tag_push_service.rb2
-rw-r--r--app/services/gravatar_service.rb21
-rw-r--r--app/services/issuable_base_service.rb12
-rw-r--r--app/services/issues/close_service.rb1
-rw-r--r--app/services/issues/reopen_service.rb1
-rw-r--r--app/services/members/create_service.rb22
-rw-r--r--app/services/merge_requests/close_service.rb1
-rw-r--r--app/services/merge_requests/conflicts/resolve_service.rb12
-rw-r--r--app/services/merge_requests/create_service.rb15
-rw-r--r--app/services/merge_requests/post_merge_service.rb1
-rw-r--r--app/services/merge_requests/refresh_service.rb4
-rw-r--r--app/services/merge_requests/reopen_service.rb3
-rw-r--r--app/services/metrics_service.rb33
-rw-r--r--app/services/notes/diff_position_update_service.rb30
-rw-r--r--app/services/notification_service.rb2
-rw-r--r--app/services/projects/create_service.rb5
-rw-r--r--app/services/projects/destroy_service.rb14
-rw-r--r--app/services/projects/transfer_service.rb11
-rw-r--r--app/services/projects/update_pages_configuration_service.rb2
-rw-r--r--app/services/search_service.rb2
-rw-r--r--app/services/slash_commands/interpret_service.rb24
-rw-r--r--app/services/submit_usage_ping_service.rb41
-rw-r--r--app/services/system_hooks_service.rb4
-rw-r--r--app/services/system_note_service.rb28
-rw-r--r--app/services/users/activity_service.rb2
-rw-r--r--app/services/users/destroy_service.rb17
-rw-r--r--app/services/web_hook_service.rb120
-rw-r--r--app/uploaders/artifact_uploader.rb28
-rw-r--r--app/uploaders/file_mover.rb63
-rw-r--r--app/uploaders/gitlab_uploader.rb8
-rw-r--r--app/uploaders/lfs_object_uploader.rb16
-rw-r--r--app/uploaders/personal_file_uploader.rb6
-rw-r--r--app/uploaders/records_uploads.rb7
-rw-r--r--app/validators/dynamic_path_validator.rb27
-rw-r--r--app/views/admin/application_settings/_form.html.haml17
-rw-r--r--app/views/admin/background_jobs/show.html.haml2
-rw-r--r--app/views/admin/conversational_development_index/_callout.html.haml13
-rw-r--r--app/views/admin/conversational_development_index/_card.html.haml25
-rw-r--r--app/views/admin/conversational_development_index/_disabled.html.haml9
-rw-r--r--app/views/admin/conversational_development_index/_no_data.html.haml7
-rw-r--r--app/views/admin/conversational_development_index/show.html.haml35
-rw-r--r--app/views/admin/dashboard/_head.html.haml2
-rw-r--r--app/views/admin/dashboard/index.html.haml6
-rw-r--r--app/views/admin/deploy_keys/edit.html.haml10
-rw-r--r--app/views/admin/deploy_keys/index.html.haml4
-rw-r--r--app/views/admin/deploy_keys/new.html.haml29
-rw-r--r--app/views/admin/health_check/show.html.haml30
-rw-r--r--app/views/admin/hook_logs/_index.html.haml37
-rw-r--r--app/views/admin/hook_logs/show.html.haml10
-rw-r--r--app/views/admin/hooks/_form.html.haml11
-rw-r--r--app/views/admin/hooks/edit.html.haml6
-rw-r--r--app/views/admin/hooks/index.html.haml2
-rw-r--r--app/views/admin/jobs/index.html.haml (renamed from app/views/admin/builds/index.html.haml)6
-rw-r--r--app/views/admin/logs/show.html.haml2
-rw-r--r--app/views/admin/monitoring/_head.html.haml (renamed from app/views/admin/background_jobs/_head.html.haml)4
-rw-r--r--app/views/admin/requests_profiles/index.html.haml4
-rw-r--r--app/views/admin/runners/index.html.haml8
-rw-r--r--app/views/admin/runners/show.html.haml2
-rw-r--r--app/views/admin/system_info/show.html.haml7
-rw-r--r--app/views/admin/users/_user.html.haml14
-rw-r--r--app/views/admin/users/show.html.haml21
-rw-r--r--app/views/award_emoji/_awards_block.html.haml4
-rw-r--r--app/views/dashboard/_activities.html.haml5
-rw-r--r--app/views/dashboard/activity.html.haml12
-rw-r--r--app/views/dashboard/issues.html.haml2
-rw-r--r--app/views/dashboard/merge_requests.html.haml2
-rw-r--r--app/views/dashboard/projects/index.html.haml22
-rw-r--r--app/views/dashboard/projects/starred.html.haml18
-rw-r--r--app/views/devise/passwords/edit.html.haml4
-rw-r--r--app/views/devise/sessions/_new_base.html.haml2
-rw-r--r--app/views/devise/shared/_signup_box.html.haml2
-rw-r--r--app/views/discussions/_diff_with_notes.html.haml2
-rw-r--r--app/views/discussions/_discussion.html.haml9
-rw-r--r--app/views/discussions/_jump_to_next.html.haml4
-rw-r--r--app/views/discussions/_notes.html.haml14
-rw-r--r--app/views/events/_commit.html.haml2
-rw-r--r--app/views/events/_event_last_push.html.haml14
-rw-r--r--app/views/events/event/_push.html.haml2
-rw-r--r--app/views/groups/_activities.html.haml3
-rw-r--r--app/views/groups/_head.html.haml3
-rw-r--r--app/views/groups/show.html.haml1
-rw-r--r--app/views/import/fogbugz/new_user_map.html.haml3
-rw-r--r--app/views/layouts/_head.html.haml1
-rw-r--r--app/views/layouts/_init_auto_complete.html.haml3
-rw-r--r--app/views/layouts/_search.html.haml2
-rw-r--r--app/views/layouts/application.html.haml2
-rw-r--r--app/views/layouts/header/_default.html.haml12
-rw-r--r--app/views/layouts/header/_new_dropdown.haml45
-rw-r--r--app/views/layouts/nav/_admin.html.haml8
-rw-r--r--app/views/layouts/nav/_profile.html.haml4
-rw-r--r--app/views/layouts/nav/_project.html.haml2
-rw-r--r--app/views/layouts/snippets.html.haml5
-rw-r--r--app/views/notify/links/ci/builds/_build.html.haml2
-rw-r--r--app/views/notify/links/ci/builds/_build.text.erb2
-rw-r--r--app/views/notify/repository_push_email.html.haml28
-rw-r--r--app/views/notify/repository_push_email.text.haml20
-rw-r--r--app/views/profiles/_event_table.html.haml3
-rw-r--r--app/views/profiles/accounts/_reset_token.html.haml11
-rw-r--r--app/views/profiles/accounts/show.html.haml34
-rw-r--r--app/views/profiles/audit_log.html.haml2
-rw-r--r--app/views/profiles/preferences/show.html.haml4
-rw-r--r--app/views/profiles/show.html.haml4
-rw-r--r--app/views/projects/_activity.html.haml4
-rw-r--r--app/views/projects/_files.html.haml10
-rw-r--r--app/views/projects/_find_file_link.html.haml2
-rw-r--r--app/views/projects/_head.html.haml15
-rw-r--r--app/views/projects/_home_panel.html.haml4
-rw-r--r--app/views/projects/_last_commit.html.haml12
-rw-r--r--app/views/projects/_last_push.html.haml34
-rw-r--r--app/views/projects/_md_preview.html.haml6
-rw-r--r--app/views/projects/_readme.html.haml21
-rw-r--r--app/views/projects/_zen.html.haml3
-rw-r--r--app/views/projects/activity.html.haml2
-rw-r--r--app/views/projects/artifacts/_tree_directory.html.haml2
-rw-r--r--app/views/projects/artifacts/_tree_file.html.haml2
-rw-r--r--app/views/projects/artifacts/browse.html.haml8
-rw-r--r--app/views/projects/artifacts/file.html.haml8
-rw-r--r--app/views/projects/blame/show.html.haml8
-rw-r--r--app/views/projects/blob/_auxiliary_viewer.html.haml5
-rw-r--r--app/views/projects/blob/_blob.html.haml24
-rw-r--r--app/views/projects/blob/_breadcrumb.html.haml36
-rw-r--r--app/views/projects/blob/_header.html.haml18
-rw-r--r--app/views/projects/blob/_markup.html.haml4
-rw-r--r--app/views/projects/blob/_new_dir.html.haml8
-rw-r--r--app/views/projects/blob/_viewer.html.haml11
-rw-r--r--app/views/projects/blob/preview.html.haml2
-rw-r--r--app/views/projects/blob/show.html.haml5
-rw-r--r--app/views/projects/blob/viewers/_changelog.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_contributing.html.haml9
-rw-r--r--app/views/projects/blob/viewers/_dependency_manager.html.haml11
-rw-r--r--app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml9
-rw-r--r--app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_license.html.haml8
-rw-r--r--app/views/projects/blob/viewers/_loading.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_loading_auxiliary.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_readme.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_route_map.html.haml9
-rw-r--r--app/views/projects/blob/viewers/_route_map_loading.html.haml4
-rw-r--r--app/views/projects/boards/_show.html.haml1
-rw-r--r--app/views/projects/boards/components/_board.html.haml11
-rw-r--r--app/views/projects/boards/components/sidebar/_assignee.html.haml5
-rw-r--r--app/views/projects/boards/components/sidebar/_milestone.html.haml3
-rw-r--r--app/views/projects/branches/_branch.html.haml40
-rw-r--r--app/views/projects/branches/_commit.html.haml2
-rw-r--r--app/views/projects/branches/_delete_protected_modal.html.haml34
-rw-r--r--app/views/projects/branches/index.html.haml2
-rw-r--r--app/views/projects/branches/new.html.haml2
-rw-r--r--app/views/projects/buttons/_download.html.haml19
-rw-r--r--app/views/projects/buttons/_dropdown.html.haml18
-rw-r--r--app/views/projects/buttons/_fork.html.haml10
-rw-r--r--app/views/projects/buttons/_koding.html.haml2
-rw-r--r--app/views/projects/buttons/_star.html.haml8
-rw-r--r--app/views/projects/ci/builds/_build.html.haml16
-rw-r--r--app/views/projects/commit/_commit_box.html.haml11
-rw-r--r--app/views/projects/commit/_pipeline.html.haml52
-rw-r--r--app/views/projects/commit/branches.html.haml28
-rw-r--r--app/views/projects/commit/show.html.haml2
-rw-r--r--app/views/projects/commits/_commit.html.haml6
-rw-r--r--app/views/projects/commits/_commits.html.haml7
-rw-r--r--app/views/projects/commits/_head.html.haml16
-rw-r--r--app/views/projects/commits/_inline_commit.html.haml2
-rw-r--r--app/views/projects/compare/_form.html.haml4
-rw-r--r--app/views/projects/compare/index.html.haml6
-rw-r--r--app/views/projects/compare/show.html.haml4
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml1
-rw-r--r--app/views/projects/deploy_keys/_deploy_key.html.haml30
-rw-r--r--app/views/projects/deploy_keys/_form.html.haml2
-rw-r--r--app/views/projects/deploy_keys/_index.html.haml14
-rw-r--r--app/views/projects/deploy_keys/edit.html.haml10
-rw-r--r--app/views/projects/deploy_keys/new.html.haml5
-rw-r--r--app/views/projects/deployments/_actions.haml1
-rw-r--r--app/views/projects/deployments/_commit.html.haml31
-rw-r--r--app/views/projects/deployments/_deployment.html.haml24
-rw-r--r--app/views/projects/diffs/_content.html.haml23
-rw-r--r--app/views/projects/diffs/_diffs.html.haml14
-rw-r--r--app/views/projects/diffs/_file.html.haml12
-rw-r--r--app/views/projects/diffs/_file_header.html.haml22
-rw-r--r--app/views/projects/diffs/_image.html.haml69
-rw-r--r--app/views/projects/diffs/_line.html.haml2
-rw-r--r--app/views/projects/diffs/_parallel_view.html.haml2
-rw-r--r--app/views/projects/diffs/_stats.html.haml6
-rw-r--r--app/views/projects/diffs/_text_file.html.haml4
-rw-r--r--app/views/projects/diffs/viewers/_image.html.haml68
-rw-r--r--app/views/projects/diffs/viewers/_text.html.haml8
-rw-r--r--app/views/projects/edit.html.haml22
-rw-r--r--app/views/projects/environments/show.html.haml18
-rw-r--r--app/views/projects/find_file/show.html.haml3
-rw-r--r--app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml4
-rw-r--r--app/views/projects/hook_logs/_index.html.haml37
-rw-r--r--app/views/projects/hook_logs/show.html.haml11
-rw-r--r--app/views/projects/hooks/edit.html.haml8
-rw-r--r--app/views/projects/issues/_discussion.html.haml6
-rw-r--r--app/views/projects/issues/_issue.html.haml4
-rw-r--r--app/views/projects/issues/_related_branches.html.haml3
-rw-r--r--app/views/projects/issues/index.html.haml7
-rw-r--r--app/views/projects/issues/show.html.haml51
-rw-r--r--app/views/projects/jobs/_header.html.haml (renamed from app/views/projects/builds/_header.html.haml)22
-rw-r--r--app/views/projects/jobs/_sidebar.html.haml (renamed from app/views/projects/builds/_sidebar.html.haml)26
-rw-r--r--app/views/projects/jobs/_table.html.haml (renamed from app/views/projects/builds/_table.html.haml)0
-rw-r--r--app/views/projects/jobs/_user.html.haml (renamed from app/views/projects/builds/_user.html.haml)0
-rw-r--r--app/views/projects/jobs/index.html.haml (renamed from app/views/projects/builds/index.html.haml)6
-rw-r--r--app/views/projects/jobs/show.html.haml (renamed from app/views/projects/builds/show.html.haml)60
-rw-r--r--app/views/projects/merge_requests/_discussion.html.haml2
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml6
-rw-r--r--app/views/projects/merge_requests/_new_compare.html.haml8
-rw-r--r--app/views/projects/merge_requests/_new_submit.html.haml4
-rw-r--r--app/views/projects/merge_requests/index.html.haml11
-rw-r--r--app/views/projects/merge_requests/show/_how_to_merge.html.haml2
-rw-r--r--app/views/projects/merge_requests/show/_mr_title.html.haml2
-rw-r--r--app/views/projects/merge_requests/show/_versions.html.haml60
-rw-r--r--app/views/projects/new.html.haml4
-rw-r--r--app/views/projects/no_repo.html.haml12
-rw-r--r--app/views/projects/notes/_actions.html.haml8
-rw-r--r--app/views/projects/notes/_more_actions_dropdown.html.haml14
-rw-r--r--app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml5
-rw-r--r--app/views/projects/pipelines/_head.html.haml2
-rw-r--r--app/views/projects/pipelines/_info.html.haml22
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml10
-rw-r--r--app/views/projects/pipelines/charts/_overall.haml6
-rw-r--r--app/views/projects/pipelines/new.html.haml4
-rw-r--r--app/views/projects/pipelines/show.html.haml6
-rw-r--r--app/views/projects/pipelines_settings/_show.html.haml6
-rw-r--r--app/views/projects/protected_branches/_dropdown.html.haml4
-rw-r--r--app/views/projects/protected_branches/_index.html.haml19
-rw-r--r--app/views/projects/protected_branches/_matching_branch.html.haml5
-rw-r--r--app/views/projects/protected_branches/_protected_branch.html.haml5
-rw-r--r--app/views/projects/protected_branches/show.html.haml2
-rw-r--r--app/views/projects/protected_tags/_create_protected_tag.html.haml2
-rw-r--r--app/views/projects/protected_tags/_dropdown.html.haml6
-rw-r--r--app/views/projects/protected_tags/_index.html.haml22
-rw-r--r--app/views/projects/protected_tags/_matching_tag.html.haml5
-rw-r--r--app/views/projects/protected_tags/_protected_tag.html.haml7
-rw-r--r--app/views/projects/protected_tags/_tags_list.html.haml4
-rw-r--r--app/views/projects/protected_tags/show.html.haml4
-rw-r--r--app/views/projects/registry/repositories/index.html.haml72
-rw-r--r--app/views/projects/runners/_runner.html.haml21
-rw-r--r--app/views/projects/runners/_specific_runners.html.haml2
-rw-r--r--app/views/projects/services/_form.html.haml13
-rw-r--r--app/views/projects/settings/_head.html.haml6
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml2
-rw-r--r--app/views/projects/settings/integrations/_project_hook.html.haml2
-rw-r--r--app/views/projects/settings/repository/show.html.haml3
-rw-r--r--app/views/projects/show.html.haml37
-rw-r--r--app/views/projects/snippets/show.html.haml2
-rw-r--r--app/views/projects/tags/_tag.html.haml7
-rw-r--r--app/views/projects/tags/new.html.haml4
-rw-r--r--app/views/projects/tags/show.html.haml4
-rw-r--r--app/views/projects/tree/_readme.html.haml17
-rw-r--r--app/views/projects/tree/_tree_content.html.haml18
-rw-r--r--app/views/projects/tree/_tree_header.html.haml21
-rw-r--r--app/views/projects/tree/show.html.haml5
-rw-r--r--app/views/projects/variables/_content.html.haml5
-rw-r--r--app/views/projects/variables/_form.html.haml9
-rw-r--r--app/views/projects/variables/_table.html.haml3
-rw-r--r--app/views/projects/wikis/_sidebar.html.haml2
-rw-r--r--app/views/search/_category.html.haml77
-rw-r--r--app/views/search/_filter.html.haml2
-rw-r--r--app/views/sent_notifications/unsubscribe.html.haml10
-rw-r--r--app/views/shared/_clone_panel.html.haml4
-rw-r--r--app/views/shared/_field.html.haml9
-rw-r--r--app/views/shared/_group_form.html.haml2
-rw-r--r--app/views/shared/_mini_pipeline_graph.html.haml2
-rw-r--r--app/views/shared/_new_project_item_select.html.haml2
-rw-r--r--app/views/shared/_no_password.html.haml8
-rw-r--r--app/views/shared/_no_ssh.html.haml9
-rw-r--r--app/views/shared/_ref_dropdown.html.haml2
-rw-r--r--app/views/shared/_ref_switcher.html.haml6
-rw-r--r--app/views/shared/_user_callout.html.haml2
-rw-r--r--app/views/shared/deploy_keys/_form.html.haml30
-rw-r--r--app/views/shared/empty_states/_issues.html.haml5
-rw-r--r--app/views/shared/empty_states/_labels.html.haml4
-rw-r--r--app/views/shared/empty_states/_merge_requests.html.haml4
-rw-r--r--app/views/shared/empty_states/icons/_pipelines_empty.svg2
-rw-r--r--app/views/shared/form_elements/_description.html.haml (renamed from app/views/shared/issuable/form/_description.html.haml)7
-rw-r--r--app/views/shared/hook_logs/_content.html.haml44
-rw-r--r--app/views/shared/hook_logs/_status_label.html.haml3
-rw-r--r--app/views/shared/icons/_convdev_no_data.svg40
-rw-r--r--app/views/shared/icons/_convdev_no_index.svg67
-rw-r--r--app/views/shared/icons/_convdev_overview.svg64
-rw-r--r--app/views/shared/icons/_i2p_step_1.svg12
-rw-r--r--app/views/shared/icons/_i2p_step_10.svg12
-rw-r--r--app/views/shared/icons/_i2p_step_2.svg5
-rw-r--r--app/views/shared/icons/_i2p_step_3.svg12
-rw-r--r--app/views/shared/icons/_i2p_step_4.svg6
-rw-r--r--app/views/shared/icons/_i2p_step_5.svg5
-rw-r--r--app/views/shared/icons/_i2p_step_6.svg15
-rw-r--r--app/views/shared/icons/_i2p_step_7.svg7
-rw-r--r--app/views/shared/icons/_i2p_step_8.svg4
-rw-r--r--app/views/shared/icons/_i2p_step_9.svg4
-rw-r--r--app/views/shared/icons/_icon_history.svg1
-rwxr-xr-xapp/views/shared/icons/_icon_status_skipped.svg2
-rw-r--r--app/views/shared/icons/_icon_status_skipped_borderless.svg2
-rw-r--r--app/views/shared/icons/_mr_widget_empty_state.svg1
-rw-r--r--app/views/shared/icons/_scroll_down.svg6
-rw-r--r--app/views/shared/icons/_scroll_down_hover_active.svg3
-rw-r--r--app/views/shared/icons/_scroll_up.svg4
-rw-r--r--app/views/shared/icons/_scroll_up_hover_active.svg3
-rw-r--r--app/views/shared/issuable/_bulk_update_sidebar.html.haml53
-rw-r--r--app/views/shared/issuable/_filter.html.haml34
-rw-r--r--app/views/shared/issuable/_form.html.haml2
-rw-r--r--app/views/shared/issuable/_label_dropdown.html.haml9
-rw-r--r--app/views/shared/issuable/_milestone_dropdown.html.haml2
-rw-r--r--app/views/shared/issuable/_nav.html.haml19
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml100
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml4
-rw-r--r--app/views/shared/issuable/_sidebar_assignees.html.haml2
-rw-r--r--app/views/shared/issuable/_user_dropdown_item.html.haml11
-rw-r--r--app/views/shared/issuable/form/_branch_chooser.html.haml4
-rw-r--r--app/views/shared/issuable/form/_merge_params.html.haml10
-rw-r--r--app/views/shared/issuable/form/_metadata_issue_assignee.html.haml2
-rw-r--r--app/views/shared/members/_access_request_buttons.html.haml7
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml2
-rw-r--r--app/views/shared/notes/_hints.html.haml27
-rw-r--r--app/views/shared/notes/_note.html.haml2
-rw-r--r--app/views/shared/notes/_notes_with_form.html.haml25
-rw-r--r--app/views/shared/notifications/_button.html.haml4
-rw-r--r--app/views/shared/notifications/_custom_notifications.html.haml17
-rw-r--r--app/views/shared/projects/_project.html.haml2
-rw-r--r--app/views/shared/snippets/_form.html.haml7
-rw-r--r--app/views/shared/snippets/_header.html.haml6
-rw-r--r--app/views/shared/web_hooks/_form.html.haml6
-rw-r--r--app/views/snippets/notes/_actions.html.haml9
-rw-r--r--app/views/snippets/show.html.haml2
-rw-r--r--app/views/users/show.html.haml8
-rw-r--r--app/workers/build_success_worker.rb11
-rw-r--r--app/workers/gitlab_usage_ping_worker.rb18
-rw-r--r--app/workers/irker_worker.rb6
-rw-r--r--app/workers/namespaceless_project_destroy_worker.rb43
-rw-r--r--app/workers/pipeline_schedule_worker.rb10
-rw-r--r--app/workers/post_receive.rb25
-rw-r--r--app/workers/process_commit_worker.rb14
-rw-r--r--app/workers/remove_old_web_hook_logs_worker.rb10
-rw-r--r--app/workers/repository_check/clear_worker.rb2
-rw-r--r--app/workers/repository_check/single_repository_worker.rb2
-rw-r--r--app/workers/repository_fork_worker.rb38
-rw-r--r--app/workers/repository_import_worker.rb24
-rw-r--r--app/workers/system_hook_worker.rb10
-rw-r--r--app/workers/web_hook_worker.rb (renamed from app/workers/project_web_hook_worker.rb)6
924 files changed, 13609 insertions, 7092 deletions
diff --git a/app/assets/images/i2p-step.svg b/app/assets/images/i2p-step.svg
new file mode 100644
index 00000000000..8886092ed82
--- /dev/null
+++ b/app/assets/images/i2p-step.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 120" enable-background="new 0 0 12 120">
+ <path d="m12 6c0-3.309-2.691-6-6-6s-6 2.691-6 6c0 2.967 2.167 5.431 5 5.91v108.09h2v-108.09c2.833-.479 5-2.943 5-5.91m-6 4c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/>
+</svg>
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
index d816df831eb..5d060165f4b 100644
--- a/app/assets/javascripts/activities.js
+++ b/app/assets/javascripts/activities.js
@@ -5,7 +5,8 @@ import Cookies from 'js-cookie';
class Activities {
constructor() {
- Pager.init(20, true, false, this.updateTooltips);
+ Pager.init(20, true, false, data => data, this.updateTooltips);
+
$('.event-filter-link').on('click', (e) => {
e.preventDefault();
this.toggleFilter(e.currentTarget);
@@ -19,7 +20,7 @@ class Activities {
reloadActivities() {
$('.content_list').html('');
- Pager.init(20, true, false, this.updateTooltips);
+ Pager.init(20, true, false, data => data, this.updateTooltips);
}
toggleFilter(sender) {
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index e5f36c84987..6680834a8d1 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -1,148 +1,175 @@
-/* eslint-disable func-names, space-before-function-paren, quotes, object-shorthand, camelcase, no-var, comma-dangle, prefer-arrow-callback, quote-props, no-param-reassign, max-len */
-
-var Api = {
- groupsPath: "/api/:version/groups.json",
- groupPath: "/api/:version/groups/:id.json",
- namespacesPath: "/api/:version/namespaces.json",
- groupProjectsPath: "/api/:version/groups/:id/projects.json",
- projectsPath: "/api/:version/projects.json?simple=true",
- labelsPath: "/:namespace_path/:project_path/labels",
- licensePath: "/api/:version/templates/licenses/:key",
- gitignorePath: "/api/:version/templates/gitignores/:key",
- gitlabCiYmlPath: "/api/:version/templates/gitlab_ci_ymls/:key",
- dockerfilePath: "/api/:version/templates/dockerfiles/:key",
- issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key",
- group: function(group_id, callback) {
- var url = Api.buildUrl(Api.groupPath)
- .replace(':id', group_id);
+import $ from 'jquery';
+
+const Api = {
+ groupsPath: '/api/:version/groups.json',
+ groupPath: '/api/:version/groups/:id.json',
+ namespacesPath: '/api/:version/namespaces.json',
+ groupProjectsPath: '/api/:version/groups/:id/projects.json',
+ projectsPath: '/api/:version/projects.json?simple=true',
+ labelsPath: '/:namespace_path/:project_path/labels',
+ licensePath: '/api/:version/templates/licenses/:key',
+ gitignorePath: '/api/:version/templates/gitignores/:key',
+ gitlabCiYmlPath: '/api/:version/templates/gitlab_ci_ymls/:key',
+ dockerfilePath: '/api/:version/templates/dockerfiles/:key',
+ issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
+ usersPath: '/api/:version/users.json',
+
+ group(groupId, callback) {
+ const url = Api.buildUrl(Api.groupPath)
+ .replace(':id', groupId);
return $.ajax({
- url: url,
- dataType: "json"
- }).done(function(group) {
- return callback(group);
- });
+ url,
+ dataType: 'json',
+ })
+ .done(group => callback(group));
},
+
// Return groups list. Filtered by query
- groups: function(query, options, callback) {
- var url = Api.buildUrl(Api.groupsPath);
+ groups(query, options, callback) {
+ const url = Api.buildUrl(Api.groupsPath);
return $.ajax({
- url: url,
- data: $.extend({
+ url,
+ data: Object.assign({
search: query,
- per_page: 20
+ per_page: 20,
}, options),
- dataType: "json"
- }).done(function(groups) {
- return callback(groups);
- });
+ dataType: 'json',
+ })
+ .done(groups => callback(groups));
},
+
// Return namespaces list. Filtered by query
- namespaces: function(query, callback) {
- var url = Api.buildUrl(Api.namespacesPath);
+ namespaces(query, callback) {
+ const url = Api.buildUrl(Api.namespacesPath);
return $.ajax({
- url: url,
+ url,
data: {
search: query,
- per_page: 20
+ per_page: 20,
},
- dataType: "json"
- }).done(function(namespaces) {
- return callback(namespaces);
- });
+ dataType: 'json',
+ }).done(namespaces => callback(namespaces));
},
+
// Return projects list. Filtered by query
- projects: function(query, options, callback) {
- var url = Api.buildUrl(Api.projectsPath);
+ projects(query, options, callback) {
+ const url = Api.buildUrl(Api.projectsPath);
return $.ajax({
- url: url,
- data: $.extend({
+ url,
+ data: Object.assign({
search: query,
per_page: 20,
- membership: true
+ membership: true,
}, options),
- dataType: "json"
- }).done(function(projects) {
- return callback(projects);
- });
+ dataType: 'json',
+ })
+ .done(projects => callback(projects));
},
- newLabel: function(namespace_path, project_path, data, callback) {
- var url = Api.buildUrl(Api.labelsPath)
- .replace(':namespace_path', namespace_path)
- .replace(':project_path', project_path);
+
+ newLabel(namespacePath, projectPath, data, callback) {
+ const url = Api.buildUrl(Api.labelsPath)
+ .replace(':namespace_path', namespacePath)
+ .replace(':project_path', projectPath);
return $.ajax({
- url: url,
- type: "POST",
- data: { 'label': data },
- dataType: "json"
- }).done(function(label) {
- return callback(label);
- }).error(function(message) {
- return callback(message.responseJSON);
- });
+ url,
+ type: 'POST',
+ data: { label: data },
+ dataType: 'json',
+ })
+ .done(label => callback(label))
+ .error(message => callback(message.responseJSON));
},
+
// Return group projects list. Filtered by query
- groupProjects: function(group_id, query, callback) {
- var url = Api.buildUrl(Api.groupProjectsPath)
- .replace(':id', group_id);
+ groupProjects(groupId, query, callback) {
+ const url = Api.buildUrl(Api.groupProjectsPath)
+ .replace(':id', groupId);
return $.ajax({
- url: url,
+ url,
data: {
search: query,
- per_page: 20
+ per_page: 20,
},
- dataType: "json"
- }).done(function(projects) {
- return callback(projects);
- });
+ dataType: 'json',
+ })
+ .done(projects => callback(projects));
},
+
// Return text for a specific license
- licenseText: function(key, data, callback) {
- var url = Api.buildUrl(Api.licensePath)
+ licenseText(key, data, callback) {
+ const url = Api.buildUrl(Api.licensePath)
.replace(':key', key);
return $.ajax({
- url: url,
- data: data
- }).done(function(license) {
- return callback(license);
- });
+ url,
+ data,
+ })
+ .done(license => callback(license));
},
- gitignoreText: function(key, callback) {
- var url = Api.buildUrl(Api.gitignorePath)
+
+ gitignoreText(key, callback) {
+ const url = Api.buildUrl(Api.gitignorePath)
.replace(':key', key);
- return $.get(url, function(gitignore) {
- return callback(gitignore);
- });
+ return $.get(url, gitignore => callback(gitignore));
},
- gitlabCiYml: function(key, callback) {
- var url = Api.buildUrl(Api.gitlabCiYmlPath)
+
+ gitlabCiYml(key, callback) {
+ const url = Api.buildUrl(Api.gitlabCiYmlPath)
.replace(':key', key);
- return $.get(url, function(file) {
- return callback(file);
- });
+ return $.get(url, file => callback(file));
},
- dockerfileYml: function(key, callback) {
- var url = Api.buildUrl(Api.dockerfilePath).replace(':key', key);
+
+ dockerfileYml(key, callback) {
+ const url = Api.buildUrl(Api.dockerfilePath).replace(':key', key);
$.get(url, callback);
},
- issueTemplate: function(namespacePath, projectPath, key, type, callback) {
- var url = Api.buildUrl(Api.issuableTemplatePath)
+
+ issueTemplate(namespacePath, projectPath, key, type, callback) {
+ const url = Api.buildUrl(Api.issuableTemplatePath)
.replace(':key', key)
.replace(':type', type)
.replace(':project_path', projectPath)
.replace(':namespace_path', namespacePath);
$.ajax({
- url: url,
- dataType: 'json'
- }).done(function(file) {
- callback(null, file);
- }).error(callback);
+ url,
+ dataType: 'json',
+ })
+ .done(file => callback(null, file))
+ .error(callback);
},
- buildUrl: function(url) {
+
+ users(query, options) {
+ const url = Api.buildUrl(this.usersPath);
+ return Api.wrapAjaxCall({
+ url,
+ data: Object.assign({
+ search: query,
+ per_page: 20,
+ }, options),
+ dataType: 'json',
+ });
+ },
+
+ buildUrl(url) {
+ let urlRoot = '';
if (gon.relative_url_root != null) {
- url = gon.relative_url_root + url;
+ urlRoot = gon.relative_url_root;
}
- return url.replace(':version', gon.api_version);
- }
+ return urlRoot + url.replace(':version', gon.api_version);
+ },
+
+ wrapAjaxCall(options) {
+ return new Promise((resolve, reject) => {
+ // jQuery 2 is not Promises/A+ compatible (missing catch)
+ $.ajax(options) // eslint-disable-line promise/catch-or-return
+ .then(data => resolve(data),
+ (jqXHR, textStatus, errorThrown) => {
+ const error = new Error(`${options.url}: ${errorThrown}`);
+ error.textStatus = textStatus;
+ reject(error);
+ },
+ );
+ });
+ },
};
-window.Api = Api;
+export default Api;
diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js
index 23d91fdb259..36ce4fddb72 100644
--- a/app/assets/javascripts/behaviors/gl_emoji.js
+++ b/app/assets/javascripts/behaviors/gl_emoji.js
@@ -88,6 +88,7 @@ function installGlEmojiElement() {
const hasCssSpriteFalback = fallbackSpriteClass && fallbackSpriteClass.length > 0;
if (
+ emojiUnicode &&
isEmojiUnicode &&
!isEmojiUnicodeSupported(generatedUnicodeSupportMap, emojiUnicode, unicodeVersion)
) {
diff --git a/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js b/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js
index 20ab2d7e827..4f8884d05ac 100644
--- a/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js
+++ b/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js
@@ -28,7 +28,8 @@ function isSkinToneComboEmoji(emojiUnicode) {
// doesn't support the skin tone versions of horse racing
const horseRacingCodePoint = 127943;// parseInt('1F3C7', 16)
function isHorceRacingSkinToneComboEmoji(emojiUnicode) {
- return Array.from(emojiUnicode)[0].codePointAt(0) === horseRacingCodePoint &&
+ const firstCharacter = Array.from(emojiUnicode)[0];
+ return firstCharacter && firstCharacter.codePointAt(0) === horseRacingCodePoint &&
isSkinToneComboEmoji(emojiUnicode);
}
diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
index cdbfe36ca1c..c17877a276d 100644
--- a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
+++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
@@ -1,5 +1,3 @@
-/* global Flash */
-
import sqljs from 'sql.js';
import { template as _template } from 'underscore';
@@ -15,19 +13,27 @@ const PREVIEW_TEMPLATE = _template(`
class BalsamiqViewer {
constructor(viewer) {
this.viewer = viewer;
- this.endpoint = this.viewer.dataset.endpoint;
}
- loadFile() {
- const xhr = new XMLHttpRequest();
+ loadFile(endpoint) {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+
+ xhr.open('GET', endpoint, true);
+ xhr.responseType = 'arraybuffer';
+ xhr.onload = loadEvent => this.fileLoaded(loadEvent, resolve, reject);
+ xhr.onerror = reject;
+
+ xhr.send();
+ });
+ }
- xhr.open('GET', this.endpoint, true);
- xhr.responseType = 'arraybuffer';
+ fileLoaded(loadEvent, resolve, reject) {
+ if (loadEvent.target.status !== 200) return reject();
- xhr.onload = this.renderFile.bind(this);
- xhr.onerror = BalsamiqViewer.onError;
+ this.renderFile(loadEvent);
- xhr.send();
+ return resolve();
}
renderFile(loadEvent) {
@@ -103,12 +109,6 @@ class BalsamiqViewer {
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
index 1dacf84470f..8641a6fdae6 100644
--- a/app/assets/javascripts/blob/balsamiq_viewer.js
+++ b/app/assets/javascripts/blob/balsamiq_viewer.js
@@ -1,6 +1,22 @@
+/* global Flash */
+
import BalsamiqViewer from './balsamiq/balsamiq_viewer';
-document.addEventListener('DOMContentLoaded', () => {
- const balsamiqViewer = new BalsamiqViewer(document.getElementById('js-balsamiq-viewer'));
- balsamiqViewer.loadFile();
-});
+function onError() {
+ const flash = new window.Flash('Balsamiq file could not be loaded.');
+
+ return flash;
+}
+
+function loadBalsamiqFile() {
+ const viewer = document.getElementById('js-balsamiq-viewer');
+
+ if (!(viewer instanceof Element)) return;
+
+ const endpoint = viewer.dataset.endpoint;
+
+ const balsamiqViewer = new BalsamiqViewer(viewer);
+ balsamiqViewer.loadFile(endpoint).catch(onError);
+}
+
+$(loadBalsamiqFile);
diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js
index ab5b3751c4e..5ae30990aea 100644
--- a/app/assets/javascripts/blob/file_template_selector.js
+++ b/app/assets/javascripts/blob/file_template_selector.js
@@ -1,5 +1,3 @@
-/* global Api */
-
export default class FileTemplateSelector {
constructor(mediator) {
this.mediator = mediator;
@@ -65,4 +63,3 @@ export default class FileTemplateSelector {
this.reportSelection(opts);
}
}
-
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 f2f81af137b..9c41e429c8d 100644
--- a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
@@ -1,4 +1,4 @@
-/* global Api */
+import Api from '../../api';
import FileTemplateSelector from '../file_template_selector';
diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
index 3cb7b960aaa..45fb614fe00 100644
--- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
@@ -1,4 +1,4 @@
-/* global Api */
+import Api from '../../api';
import FileTemplateSelector from '../file_template_selector';
diff --git a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
index 7efda8e7f50..a894953cc86 100644
--- a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
@@ -1,4 +1,4 @@
-/* global Api */
+import Api from '../../api';
import FileTemplateSelector from '../file_template_selector';
diff --git a/app/assets/javascripts/blob/template_selectors/license_selector.js b/app/assets/javascripts/blob/template_selectors/license_selector.js
index 1d757332f6c..b7c4da0f62e 100644
--- a/app/assets/javascripts/blob/template_selectors/license_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/license_selector.js
@@ -1,4 +1,4 @@
-/* global Api */
+import Api from '../../api';
import FileTemplateSelector from '../file_template_selector';
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index d06387c0f4d..187fab084fd 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -1,17 +1,38 @@
/* global Flash */
export default class BlobViewer {
constructor() {
+ BlobViewer.initAuxiliaryViewer();
+
+ this.initMainViewers();
+ }
+
+ static initAuxiliaryViewer() {
+ const auxiliaryViewer = document.querySelector('.blob-viewer[data-type="auxiliary"]');
+ if (!auxiliaryViewer) return;
+
+ BlobViewer.loadViewer(auxiliaryViewer);
+ }
+
+ initMainViewers() {
+ this.$fileHolder = $('.file-holder');
+ if (!this.$fileHolder.length) return;
+
this.switcher = document.querySelector('.js-blob-viewer-switcher');
this.switcherBtns = document.querySelectorAll('.js-blob-viewer-switch-btn');
this.copySourceBtn = document.querySelector('.js-copy-blob-source-btn');
- this.simpleViewer = document.querySelector('.blob-viewer[data-type="simple"]');
- this.richViewer = document.querySelector('.blob-viewer[data-type="rich"]');
- this.$fileHolder = $('.file-holder');
- let initialViewerName = document.querySelector('.blob-viewer:not(.hidden)').getAttribute('data-type');
+ this.simpleViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="simple"]');
+ this.richViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="rich"]');
this.initBindings();
+ this.switchToInitialViewer();
+ }
+
+ switchToInitialViewer() {
+ const initialViewer = this.$fileHolder[0].querySelector('.blob-viewer:not(.hidden)');
+ let initialViewerName = initialViewer.getAttribute('data-type');
+
if (this.switcher && location.hash.indexOf('#L') === 0) {
initialViewerName = 'simple';
}
@@ -61,41 +82,13 @@ export default class BlobViewer {
$(this.copySourceBtn).tooltip('fixTitle');
}
- loadViewer(viewerParam) {
- const viewer = viewerParam;
- const url = viewer.getAttribute('data-url');
-
- if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) {
- return;
- }
-
- viewer.setAttribute('data-loading', 'true');
-
- $.ajax({
- url,
- dataType: 'JSON',
- })
- .fail(() => new Flash('Error loading source view'))
- .done((data) => {
- viewer.innerHTML = data.html;
- $(viewer).syntaxHighlight();
-
- viewer.setAttribute('data-loaded', 'true');
-
- this.$fileHolder.trigger('highlight:line');
- gl.utils.handleLocationHash();
-
- this.toggleCopyButtonState();
- });
- }
-
switchToViewer(name) {
- const newViewer = document.querySelector(`.blob-viewer[data-type='${name}']`);
+ const newViewer = this.$fileHolder[0].querySelector(`.blob-viewer[data-type='${name}']`);
if (this.activeViewer === newViewer) return;
const oldButton = document.querySelector('.js-blob-viewer-switch-btn.active');
const newButton = document.querySelector(`.js-blob-viewer-switch-btn[data-viewer='${name}']`);
- const oldViewer = document.querySelector(`.blob-viewer:not([data-type='${name}'])`);
+ const oldViewer = this.$fileHolder[0].querySelector(`.blob-viewer:not([data-type='${name}'])`);
if (oldButton) {
oldButton.classList.remove('active');
@@ -116,6 +109,41 @@ export default class BlobViewer {
this.toggleCopyButtonState();
- this.loadViewer(newViewer);
+ BlobViewer.loadViewer(newViewer)
+ .then((viewer) => {
+ $(viewer).renderGFM();
+
+ this.$fileHolder.trigger('highlight:line');
+ gl.utils.handleLocationHash();
+
+ this.toggleCopyButtonState();
+ })
+ .catch(() => new Flash('Error loading viewer'));
+ }
+
+ static loadViewer(viewerParam) {
+ const viewer = viewerParam;
+ const url = viewer.getAttribute('data-url');
+
+ return new Promise((resolve, reject) => {
+ if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) {
+ resolve(viewer);
+ return;
+ }
+
+ viewer.setAttribute('data-loading', 'true');
+
+ $.ajax({
+ url,
+ dataType: 'JSON',
+ })
+ .fail(reject)
+ .done((data) => {
+ viewer.innerHTML = data.html;
+ viewer.setAttribute('data-loaded', 'true');
+
+ resolve(viewer);
+ });
+ });
}
}
diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js
index 88eb4251339..b94009ee76b 100644
--- a/app/assets/javascripts/boards/boards_bundle.js
+++ b/app/assets/javascripts/boards/boards_bundle.js
@@ -6,23 +6,22 @@ import Vue from 'vue';
import VueResource from 'vue-resource';
import FilteredSearchBoards from './filtered_search_boards';
import eventHub from './eventhub';
-
-require('./models/issue');
-require('./models/label');
-require('./models/list');
-require('./models/milestone');
-require('./models/assignee');
-require('./stores/boards_store');
-require('./stores/modal_store');
-require('./services/board_service');
-require('./mixins/modal_mixins');
-require('./mixins/sortable_default_options');
-require('./filters/due_date_filters');
-require('./components/board');
-require('./components/board_sidebar');
-require('./components/new_list_dropdown');
-require('./components/modal/index');
-require('../vue_shared/vue_resource_interceptor');
+import './models/issue';
+import './models/label';
+import './models/list';
+import './models/milestone';
+import './models/assignee';
+import './stores/boards_store';
+import './stores/modal_store';
+import './services/board_service';
+import './mixins/modal_mixins';
+import './mixins/sortable_default_options';
+import './filters/due_date_filters';
+import './components/board';
+import './components/board_sidebar';
+import './components/new_list_dropdown';
+import './components/modal/index';
+import '../vue_shared/vue_resource_interceptor';
Vue.use(VueResource);
@@ -71,6 +70,7 @@ $(() => {
gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId);
this.filterManager = new FilteredSearchBoards(Store.filter, true);
+ this.filterManager.setup();
// Listen for updateTokens event
eventHub.$on('updateTokens', this.updateTokens);
@@ -88,6 +88,8 @@ $(() => {
if (list.type === 'closed') {
list.position = Infinity;
list.label = { description: 'Shows all closed issues. Moving an issue to this list closes it' };
+ } else if (list.type === 'backlog') {
+ list.position = -1;
}
});
@@ -128,7 +130,7 @@ $(() => {
},
computed: {
disabled() {
- return !this.store.lists.filter(list => list.type !== 'blank' && list.type !== 'done').length;
+ return !this.store.lists.filter(list => !list.preset).length;
},
tooltipTitle() {
if (this.disabled) {
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js
index 0d23bdeeb99..adb7360327c 100644
--- a/app/assets/javascripts/boards/components/board.js
+++ b/app/assets/javascripts/boards/components/board.js
@@ -1,11 +1,10 @@
/* eslint-disable comma-dangle, space-before-function-paren, one-var */
/* global Sortable */
import Vue from 'vue';
+import AccessorUtilities from '../../lib/utils/accessor';
import boardList from './board_list';
import boardBlankState from './board_blank_state';
-
-require('./board_delete');
-require('./board_list');
+import './board_delete';
const Store = gl.issueBoards.BoardsStore;
@@ -24,6 +23,10 @@ gl.issueBoards.Board = Vue.extend({
disabled: Boolean,
issueLinkBase: String,
rootPath: String,
+ boardId: {
+ type: String,
+ required: true,
+ },
},
data () {
return {
@@ -80,7 +83,16 @@ gl.issueBoards.Board = Vue.extend({
methods: {
showNewIssueForm() {
this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm;
- }
+ },
+ toggleExpanded(e) {
+ if (this.list.isExpandable && !e.target.classList.contains('js-no-trigger-collapse')) {
+ this.list.isExpanded = !this.list.isExpanded;
+
+ if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ localStorage.setItem(`boards.${this.boardId}.${this.list.type}.expanded`, this.list.isExpanded);
+ }
+ }
+ },
},
mounted () {
this.sortableOptions = gl.issueBoards.getBoardSortableDefaultOptions({
@@ -104,4 +116,11 @@ gl.issueBoards.Board = Vue.extend({
this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions);
},
+ created() {
+ if (this.list.isExpandable && AccessorUtilities.isLocalStorageAccessSafe()) {
+ const isCollapsed = localStorage.getItem(`boards.${this.boardId}.${this.list.type}.expanded`) === 'false';
+
+ this.list.isExpanded = !isCollapsed;
+ }
+ },
});
diff --git a/app/assets/javascripts/boards/components/board_card.js b/app/assets/javascripts/boards/components/board_card.js
index f591134c548..079fb6438b9 100644
--- a/app/assets/javascripts/boards/components/board_card.js
+++ b/app/assets/javascripts/boards/components/board_card.js
@@ -1,4 +1,4 @@
-require('./issue_card_inner');
+import './issue_card_inner';
const Store = gl.issueBoards.BoardsStore;
diff --git a/app/assets/javascripts/boards/components/board_list.js b/app/assets/javascripts/boards/components/board_list.js
index 49a775002c3..bebca17fb1e 100644
--- a/app/assets/javascripts/boards/components/board_list.js
+++ b/app/assets/javascripts/boards/components/board_list.js
@@ -2,6 +2,7 @@
import boardNewIssue from './board_new_issue';
import boardCard from './board_card';
import eventHub from '../eventhub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
const Store = gl.issueBoards.BoardsStore;
@@ -44,6 +45,7 @@ export default {
components: {
boardCard,
boardNewIssue,
+ loadingIcon,
},
methods: {
listHeight() {
@@ -55,6 +57,9 @@ export default {
scrollTop() {
return this.$refs.list.scrollTop + this.listHeight();
},
+ scrollToTop() {
+ this.$refs.list.scrollTop = 0;
+ },
loadNextPage() {
const getIssues = this.list.nextPage();
const loadingDone = () => {
@@ -106,6 +111,7 @@ export default {
},
created() {
eventHub.$on(`hide-issue-form-${this.list.id}`, this.toggleForm);
+ eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
},
mounted() {
const options = gl.issueBoards.getBoardSortableDefaultOptions({
@@ -148,6 +154,7 @@ export default {
},
beforeDestroy() {
eventHub.$off(`hide-issue-form-${this.list.id}`, this.toggleForm);
+ eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
this.$refs.list.removeEventListener('scroll', this.onScroll);
},
template: `
@@ -156,14 +163,13 @@ export default {
class="board-list-loading text-center"
aria-label="Loading issues"
v-if="loading">
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true">
- </i>
+ <loading-icon />
</div>
- <board-new-issue
- :list="list"
- v-if="list.type !== 'closed' && showIssueForm"/>
+ <transition name="slide-down">
+ <board-new-issue
+ :list="list"
+ v-if="list.type !== 'closed' && showIssueForm"/>
+ </transition>
<ul
class="board-list"
v-show="!loading"
@@ -184,12 +190,12 @@ export default {
class="board-list-count text-center"
v-if="showCount"
data-id="-1">
- <i
- class="fa fa-spinner fa-spin"
- aria-label="Loading more issues"
- aria-hidden="true"
- v-show="list.loadingMore">
- </i>
+
+ <loading-icon
+ v-show="list.loadingMore"
+ label="Loading more issues"
+ />
+
<span v-if="list.issues.length === list.issuesSize">
Showing all issues
</span>
diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js
index 1ce95b62138..b1c47b09c35 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.js
+++ b/app/assets/javascripts/boards/components/board_new_issue.js
@@ -48,6 +48,7 @@ export default {
this.error = true;
});
+ eventHub.$emit(`scroll-board-list-${this.list.id}`);
this.cancel();
},
cancel() {
@@ -75,6 +76,7 @@ export default {
type="text"
v-model="title"
ref="input"
+ autocomplete="off"
:id="list.id + '-title'" />
<div class="clearfix prepend-top-10">
<button class="btn btn-success pull-left"
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index 60b58b6fd41..386102032cb 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -7,11 +7,9 @@
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');
+import './sidebar/remove_issue';
const Store = gl.issueBoards.BoardsStore;
@@ -36,6 +34,9 @@ gl.issueBoards.BoardSidebar = Vue.extend({
},
assigneeId() {
return this.issue.assignee ? this.issue.assignee.id : 0;
+ },
+ milestoneTitle() {
+ return this.issue.milestone ? this.issue.milestone.title : 'No Milestone';
}
},
watch: {
@@ -62,18 +63,6 @@ gl.issueBoards.BoardSidebar = Vue.extend({
},
deep: true
},
- issue () {
- if (this.showSidebar) {
- this.$nextTick(() => {
- $('.right-sidebar').getNiceScroll(0).doScrollTop(0, 0);
- $('.right-sidebar').getNiceScroll().resize();
- });
- }
-
- this.issue = this.detail.issue;
- this.list = this.detail.list;
- },
- deep: true
},
methods: {
closeSidebar () {
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js
index 710207db0c7..daef01bc93d 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.js
+++ b/app/assets/javascripts/boards/components/issue_card_inner.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../eventhub';
const Store = gl.issueBoards.BoardsStore;
@@ -38,6 +39,9 @@ gl.issueBoards.IssueCardInner = Vue.extend({
maxCounter: 99,
};
},
+ components: {
+ userAvatarLink,
+ },
computed: {
numberOverLimit() {
return this.issue.assignees.length - this.limitBeforeCounter;
@@ -146,23 +150,17 @@ gl.issueBoards.IssueCardInner = Vue.extend({
</span>
</h4>
<div class="card-assignee">
- <a
- class="has-tooltip js-no-trigger"
- :href="assigneeUrl(assignee)"
- :title="assigneeUrlTitle(assignee)"
+ <user-avatar-link
v-for="(assignee, index) in issue.assignees"
+ :key="assignee.id"
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>
+ class="js-no-trigger"
+ :link-href="assigneeUrl(assignee)"
+ :img-alt="avatarUrlTitle(assignee)"
+ :img-src="assignee.avatar"
+ :tooltip-text="assigneeUrlTitle(assignee)"
+ tooltip-placement="bottom"
+ />
<span
class="avatar-counter has-tooltip"
:title="assigneeCounterTooltip"
diff --git a/app/assets/javascripts/boards/components/modal/filters.js b/app/assets/javascripts/boards/components/modal/filters.js
index b214b5a7199..56a0fde5a91 100644
--- a/app/assets/javascripts/boards/components/modal/filters.js
+++ b/app/assets/javascripts/boards/components/modal/filters.js
@@ -13,6 +13,7 @@ export default {
FilteredSearchContainer.container = this.$el;
this.filteredSearch = new FilteredSearchBoards(this.store);
+ this.filteredSearch.setup();
this.filteredSearch.removeTokens();
this.filteredSearch.handleInputPlaceholder();
this.filteredSearch.toggleClearSearchButton();
diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js
index ccd270b27da..478a1335b2b 100644
--- a/app/assets/javascripts/boards/components/modal/footer.js
+++ b/app/assets/javascripts/boards/components/modal/footer.js
@@ -2,8 +2,7 @@
/* global Flash */
import Vue from 'vue';
-
-require('./lists_dropdown');
+import './lists_dropdown';
const ModalStore = gl.issueBoards.ModalStore;
@@ -27,7 +26,8 @@ gl.issueBoards.ModalFooter = Vue.extend({
},
methods: {
addIssues() {
- const list = this.modal.selectedList || this.state.lists[0];
+ const firstListIndex = 1;
+ const list = this.modal.selectedList || this.state.lists[firstListIndex];
const selectedIssues = ModalStore.getSelectedIssues();
const issueIds = selectedIssues.map(issue => issue.globalId);
diff --git a/app/assets/javascripts/boards/components/modal/header.js b/app/assets/javascripts/boards/components/modal/header.js
index e2b3f9ae7e2..31f59d295bf 100644
--- a/app/assets/javascripts/boards/components/modal/header.js
+++ b/app/assets/javascripts/boards/components/modal/header.js
@@ -1,7 +1,6 @@
import Vue from 'vue';
import modalFilters from './filters';
-
-require('./tabs');
+import './tabs';
const ModalStore = gl.issueBoards.ModalStore;
diff --git a/app/assets/javascripts/boards/components/modal/index.js b/app/assets/javascripts/boards/components/modal/index.js
index a61cc7954a1..6356c266ee2 100644
--- a/app/assets/javascripts/boards/components/modal/index.js
+++ b/app/assets/javascripts/boards/components/modal/index.js
@@ -2,11 +2,11 @@
import Vue from 'vue';
import queryData from '../../utils/query_data';
-
-require('./header');
-require('./list');
-require('./footer');
-require('./empty_state');
+import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
+import './header';
+import './list';
+import './footer';
+import './empty_state';
const ModalStore = gl.issueBoards.ModalStore;
@@ -137,6 +137,7 @@ gl.issueBoards.IssuesModal = Vue.extend({
'modal-list': gl.issueBoards.ModalList,
'modal-footer': gl.issueBoards.ModalFooter,
'empty-state': gl.issueBoards.ModalEmptyState,
+ loadingIcon,
},
template: `
<div
@@ -161,7 +162,7 @@ gl.issueBoards.IssuesModal = Vue.extend({
class="add-issues-list text-center"
v-if="loading || filterLoading">
<div class="add-issues-list-loading">
- <i class="fa fa-spinner fa-spin"></i>
+ <loading-icon />
</div>
</section>
<modal-footer></modal-footer>
diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.js b/app/assets/javascripts/boards/components/modal/lists_dropdown.js
index 8cd15df90fa..4684ea76647 100644
--- a/app/assets/javascripts/boards/components/modal/lists_dropdown.js
+++ b/app/assets/javascripts/boards/components/modal/lists_dropdown.js
@@ -11,7 +11,7 @@ gl.issueBoards.ModalFooterListsDropdown = Vue.extend({
},
computed: {
selected() {
- return this.modal.selectedList || this.state.lists[0];
+ return this.modal.selectedList || this.state.lists[1];
},
},
destroyed() {
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
index 1264280284c..b37698fe9ca 100644
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ b/app/assets/javascripts/boards/filtered_search_boards.js
@@ -2,7 +2,7 @@
import FilteredSearchContainer from '../filtered_search/container';
export default class FilteredSearchBoards extends gl.FilteredSearchManager {
- constructor(store, updateUrl = false) {
+ constructor(store, updateUrl = false, cantEdit = []) {
super('boards');
this.store = store;
@@ -11,6 +11,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
// Issue boards is slightly different, we handle all the requests async
// instead or reloading the page, we just re-fire the list ajax requests
this.isHandledAsync = true;
+
+ this.cantEdit = cantEdit;
}
updateObject(path) {
@@ -40,4 +42,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
// Get the placeholder back if search is empty
this.filteredSearchInput.dispatchEvent(new Event('input'));
}
+
+ canEdit(tokenName) {
+ return this.cantEdit.indexOf(tokenName) === -1;
+ }
}
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index 90561d0f7a8..548de1a4c52 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -12,7 +12,9 @@ class List {
this.position = obj.position;
this.title = obj.title;
this.type = obj.list_type;
- this.preset = ['closed', 'blank'].indexOf(this.type) > -1;
+ this.preset = ['backlog', 'closed', 'blank'].indexOf(this.type) > -1;
+ this.isExpandable = ['backlog', 'closed'].indexOf(this.type) > -1;
+ this.isExpanded = true;
this.page = 1;
this.loading = true;
this.loadingMore = false;
@@ -103,13 +105,19 @@ class List {
}
newIssue (issue) {
- this.addIssue(issue);
+ this.addIssue(issue, null, 0);
this.issuesSize += 1;
return gl.boardService.newIssue(this.id, issue)
.then((resp) => {
const data = resp.json();
issue.id = data.iid;
+ })
+ .then(() => {
+ if (this.issuesSize > 1) {
+ const moveBeforeIid = this.issues[1].id;
+ gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeIid);
+ }
});
}
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index ad9997ac334..1e12d4ca415 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -22,6 +22,7 @@ gl.issueBoards.BoardsStore = {
create () {
this.state.lists = [];
this.filter.path = gl.utils.getUrlParamsArray().join('&');
+ this.detail = { issue: {} };
},
addList (listObj, defaultAvatar) {
const list = new List(listObj, defaultAvatar);
@@ -31,10 +32,14 @@ gl.issueBoards.BoardsStore = {
},
new (listObj) {
const list = this.addList(listObj);
+ const backlogList = this.findList('type', 'backlog', 'backlog');
list
.save()
.then(() => {
+ // Remove any new issues from the backlog
+ // as they will be visible in the new list
+ list.issues.forEach(backlogList.removeIssue.bind(backlogList));
this.state.lists = _.sortBy(this.state.lists, 'position');
})
.catch(() => {
@@ -47,7 +52,7 @@ gl.issueBoards.BoardsStore = {
},
shouldAddBlankState () {
// Decide whether to add the blank state
- return !(this.state.lists.filter(list => list.type !== 'closed')[0]);
+ return !(this.state.lists.filter(list => list.type !== 'backlog' && list.type !== 'closed')[0]);
},
addBlankState () {
if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return;
@@ -100,7 +105,7 @@ gl.issueBoards.BoardsStore = {
issueTo.removeLabel(listFrom.label);
}
- if (listTo.type === 'closed') {
+ if (listTo.type === 'closed' && listFrom.type !== 'backlog') {
issueLists.forEach((list) => {
list.removeIssue(issue);
});
diff --git a/app/assets/javascripts/branches/branches_delete_modal.js b/app/assets/javascripts/branches/branches_delete_modal.js
new file mode 100644
index 00000000000..af8bcdc1794
--- /dev/null
+++ b/app/assets/javascripts/branches/branches_delete_modal.js
@@ -0,0 +1,36 @@
+const MODAL_SELECTOR = '#modal-delete-branch';
+
+class DeleteModal {
+ constructor() {
+ this.$modal = $(MODAL_SELECTOR);
+ this.$toggleBtns = $(`[data-target="${MODAL_SELECTOR}"]`);
+ this.$branchName = $('.js-branch-name', this.$modal);
+ this.$confirmInput = $('.js-delete-branch-input', this.$modal);
+ this.$deleteBtn = $('.js-delete-branch', this.$modal);
+ this.bindEvents();
+ }
+
+ bindEvents() {
+ this.$toggleBtns.on('click', this.setModalData.bind(this));
+ this.$confirmInput.on('input', this.setDeleteDisabled.bind(this));
+ }
+
+ setModalData(e) {
+ this.branchName = e.currentTarget.dataset.branchName || '';
+ this.deletePath = e.currentTarget.dataset.deletePath || '';
+ this.updateModal();
+ }
+
+ setDeleteDisabled(e) {
+ this.$deleteBtn.attr('disabled', e.currentTarget.value !== this.branchName);
+ }
+
+ updateModal() {
+ this.$branchName.text(this.branchName);
+ this.$confirmInput.val('');
+ this.$deleteBtn.attr('href', this.deletePath);
+ this.$deleteBtn.attr('disabled', true);
+ }
+}
+
+export default DeleteModal;
diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js
index 97f279e4be4..072a899e9f2 100644
--- a/app/assets/javascripts/build.js
+++ b/app/assets/javascripts/build.js
@@ -2,15 +2,11 @@
consistent-return, prefer-rest-params */
/* global Breakpoints */
+import _ from 'underscore';
import { bytesToKiB } from './lib/utils/number_utils';
-const bind = function (fn, me) { return function () { return fn.apply(me, arguments); }; };
-const AUTO_SCROLL_OFFSET = 75;
-const DOWN_BUILD_TRACE = '#down-build-trace';
-
window.Build = (function () {
Build.timeout = null;
-
Build.state = null;
function Build(options) {
@@ -23,21 +19,22 @@ window.Build = (function () {
this.buildStage = this.options.buildStage;
this.$document = $(document);
this.logBytes = 0;
+ this.scrollOffsetPadding = 30;
- this.updateDropdown = bind(this.updateDropdown, this);
+ this.updateDropdown = this.updateDropdown.bind(this);
+ this.getBuildTrace = this.getBuildTrace.bind(this);
+ this.scrollToBottom = this.scrollToBottom.bind(this);
this.$body = $('body');
this.$buildTrace = $('#build-trace');
- this.$autoScrollContainer = $('.autoscroll-container');
- this.$autoScrollStatus = $('#autoscroll-status');
- this.$autoScrollStatusText = this.$autoScrollStatus.find('.status-text');
- this.$upBuildTrace = $('#up-build-trace');
- this.$downBuildTrace = $(DOWN_BUILD_TRACE);
- this.$scrollTopBtn = $('#scroll-top');
- this.$scrollBottomBtn = $('#scroll-bottom');
this.$buildRefreshAnimation = $('.js-build-refresh');
- this.$buildScroll = $('#js-build-scroll');
this.$truncatedInfo = $('.js-truncated-info');
+ this.$buildTraceOutput = $('.js-build-output');
+ this.$scrollContainer = $('.js-scroll-container');
+
+ // Scroll controllers
+ this.$scrollTopBtn = $('.js-scroll-up');
+ this.$scrollBottomBtn = $('.js-scroll-down');
clearTimeout(Build.timeout);
// Init breakpoint checker
@@ -56,54 +53,149 @@ window.Build = (function () {
.off('click', '.stage-item')
.on('click', '.stage-item', this.updateDropdown);
- this.$document.on('scroll', this.initScrollMonitor.bind(this));
+ // add event listeners to the scroll buttons
+ this.$scrollTopBtn
+ .off('click')
+ .on('click', this.scrollToTop.bind(this));
+
+ this.$scrollBottomBtn
+ .off('click')
+ .on('click', this.scrollToBottom.bind(this));
$(window)
.off('resize.build')
- .on('resize.build', this.sidebarOnResize.bind(this));
-
- $('a', this.$buildScroll)
- .off('click.stepTrace')
- .on('click.stepTrace', this.stepTrace);
+ .on('resize.build', _.throttle(this.sidebarOnResize.bind(this), 100));
this.updateArtifactRemoveDate();
- this.initScrollButtonAffix();
- this.invokeBuildTrace();
+
+ // eslint-disable-next-line
+ this.getBuildTrace()
+ .then(() => this.makeTraceScrollable())
+ .then(() => this.scrollToBottom());
+
+ this.verifyTopPosition();
}
+ Build.prototype.makeTraceScrollable = function () {
+ this.$scrollContainer.niceScroll({
+ cursorcolor: '#fff',
+ cursoropacitymin: 1,
+ cursorwidth: '3px',
+ railpadding: { top: 5, bottom: 5, right: 5 },
+ });
+
+ this.$scrollContainer.on('scroll', _.throttle(this.toggleScroll.bind(this), 100));
+
+ this.toggleScroll();
+ };
+
+ Build.prototype.canScroll = function () {
+ return (this.$scrollContainer.prop('scrollHeight') - this.scrollOffsetPadding) > this.$scrollContainer.height();
+ };
+
+ /**
+ * | | Up | Down |
+ * |--------------------------|----------|----------|
+ * | on scroll bottom | active | disabled |
+ * | on scroll top | disabled | active |
+ * | no scroll | disabled | disabled |
+ * | on.('scroll') is on top | disabled | active |
+ * | on('scroll) is on bottom | active | disabled |
+ *
+ */
+ Build.prototype.toggleScroll = function () {
+ const bottomScroll = this.$scrollContainer.scrollTop() +
+ this.scrollOffsetPadding +
+ this.$scrollContainer.height();
+
+ if (this.canScroll()) {
+ if (this.$scrollContainer.scrollTop() === 0) {
+ this.toggleDisableButton(this.$scrollTopBtn, true);
+ this.toggleDisableButton(this.$scrollBottomBtn, false);
+ } else if (bottomScroll === this.$scrollContainer.prop('scrollHeight')) {
+ this.toggleDisableButton(this.$scrollTopBtn, false);
+ this.toggleDisableButton(this.$scrollBottomBtn, true);
+ } else {
+ this.toggleDisableButton(this.$scrollTopBtn, false);
+ this.toggleDisableButton(this.$scrollBottomBtn, false);
+ }
+ }
+ };
+
+ Build.prototype.scrollToTop = function () {
+ this.$scrollContainer.getNiceScroll(0).doScrollTop(0);
+ this.toggleScroll();
+ };
+
+ Build.prototype.scrollToBottom = function () {
+ this.$scrollContainer.getNiceScroll(0).doScrollTo(this.$scrollContainer.prop('scrollHeight'));
+ this.toggleScroll();
+ };
+
+ Build.prototype.toggleDisableButton = function ($button, disable) {
+ if (disable && $button.prop('disabled')) return;
+ $button.prop('disabled', disable);
+ };
+
+ Build.prototype.toggleScrollAnimation = function (toggle) {
+ this.$scrollBottomBtn.toggleClass('animate', toggle);
+ };
+
+ /**
+ * Build trace top position depends on the space ocupied by the elments rendered before
+ */
+ Build.prototype.verifyTopPosition = function () {
+ const $buildPage = $('.build-page');
+
+ const $header = $('.build-header', $buildPage);
+ const $runnersStuck = $('.js-build-stuck', $buildPage);
+ const $startsEnvironment = $('.js-environment-container', $buildPage);
+ const $erased = $('.js-build-erased', $buildPage);
+
+ let topPostion = 168;
+
+ if ($header) {
+ topPostion += $header.outerHeight();
+ }
+
+ if ($runnersStuck) {
+ topPostion += $runnersStuck.outerHeight();
+ }
+
+ if ($startsEnvironment) {
+ topPostion += $startsEnvironment.outerHeight();
+ }
+
+ if ($erased) {
+ topPostion += $erased.outerHeight() + 10;
+ }
+
+ this.$buildTrace.css({
+ top: topPostion,
+ });
+ };
+
Build.prototype.initSidebar = function () {
this.$sidebar = $('.js-build-sidebar');
this.$sidebar.niceScroll();
- this.$document
- .off('click', '.js-sidebar-build-toggle')
- .on('click', '.js-sidebar-build-toggle', this.toggleSidebar);
- };
-
- Build.prototype.invokeBuildTrace = function () {
- return this.getBuildTrace();
};
Build.prototype.getBuildTrace = function () {
return $.ajax({
url: `${this.pageUrl}/trace.json`,
- dataType: 'json',
- data: {
- state: this.state,
- },
- success: ((log) => {
- const $buildContainer = $('.js-build-output');
-
+ data: this.state,
+ })
+ .done((log) => {
gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`);
-
if (log.state) {
this.state = log.state;
}
if (log.append) {
- $buildContainer.append(log.html);
+ this.$buildTraceOutput.append(log.html);
this.logBytes += log.size;
} else {
- $buildContainer.html(log.html);
+ this.$buildTraceOutput.html(log.html);
this.logBytes = log.size;
}
@@ -114,141 +206,30 @@ window.Build = (function () {
const size = bytesToKiB(this.logBytes);
$('.js-truncated-info-size').html(`${size}`);
this.$truncatedInfo.removeClass('hidden');
- this.initAffixTruncatedInfo();
} else {
this.$truncatedInfo.addClass('hidden');
}
- this.checkAutoscroll();
-
if (!log.complete) {
+ this.toggleScrollAnimation(true);
+
Build.timeout = setTimeout(() => {
- this.invokeBuildTrace();
+ //eslint-disable-next-line
+ this.getBuildTrace()
+ .then(() => this.scrollToBottom());
}, 4000);
} else {
this.$buildRefreshAnimation.remove();
+ this.toggleScrollAnimation(false);
}
if (log.status !== this.buildStatus) {
- let pageUrl = this.pageUrl;
-
- if (this.$autoScrollStatus.data('state') === 'enabled') {
- pageUrl += DOWN_BUILD_TRACE;
- }
-
- gl.utils.visitUrl(pageUrl);
+ gl.utils.visitUrl(this.pageUrl);
}
- }),
- error: () => {
+ })
+ .fail(() => {
this.$buildRefreshAnimation.remove();
- return this.initScrollMonitor();
- },
- });
- };
-
- Build.prototype.checkAutoscroll = function () {
- if (this.$autoScrollStatus.data('state') === 'enabled') {
- return $('html,body').scrollTop(this.$buildTrace.height());
- }
-
- // Handle a situation where user started new build
- // but never scrolled a page
- if (!this.$scrollTopBtn.is(':visible') &&
- !this.$scrollBottomBtn.is(':visible') &&
- !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
- this.$scrollBottomBtn.show();
- }
- };
-
- Build.prototype.initScrollButtonAffix = function () {
- // Hide everything initially
- this.$scrollTopBtn.hide();
- this.$scrollBottomBtn.hide();
- this.$autoScrollContainer.hide();
- };
-
- // Page scroll listener to detect if user has scrolling page
- // and handle following cases
- // 1) User is at Top of Build Log;
- // - Hide Top Arrow button
- // - Show Bottom Arrow button
- // - Disable Autoscroll and hide indicator (when build is running)
- // 2) User is at Bottom of Build Log;
- // - Show Top Arrow button
- // - Hide Bottom Arrow button
- // - Enable Autoscroll and show indicator (when build is running)
- // 3) User is somewhere in middle of Build Log;
- // - Show Top Arrow button
- // - Show Bottom Arrow button
- // - Disable Autoscroll and hide indicator (when build is running)
- Build.prototype.initScrollMonitor = function () {
- if (!gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
- !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
- // User is somewhere in middle of Build Log
-
- this.$scrollTopBtn.show();
-
- if (this.buildStatus === 'success' || this.buildStatus === 'failed') { // Check if Build is completed
- this.$scrollBottomBtn.show();
- } else if (this.$buildRefreshAnimation.is(':visible') &&
- !gl.utils.isInViewport(this.$buildRefreshAnimation.get(0))) {
- this.$scrollBottomBtn.show();
- } else {
- this.$scrollBottomBtn.hide();
- }
-
- // Hide Autoscroll Status Indicator
- if (this.$scrollBottomBtn.is(':visible')) {
- this.$autoScrollContainer.hide();
- this.$autoScrollStatusText.removeClass('animate');
- } else {
- this.$autoScrollContainer.css({
- top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET,
- }).show();
- this.$autoScrollStatusText.addClass('animate');
- }
- } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
- !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
- // User is at Top of Build Log
-
- this.$scrollTopBtn.hide();
- this.$scrollBottomBtn.show();
-
- this.$autoScrollContainer.hide();
- this.$autoScrollStatusText.removeClass('animate');
- } else if ((!gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
- gl.utils.isInViewport(this.$downBuildTrace.get(0))) ||
- (this.$buildRefreshAnimation.is(':visible') &&
- gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)))) {
- // User is at Bottom of Build Log
-
- this.$scrollTopBtn.show();
- this.$scrollBottomBtn.hide();
-
- // Show and Reposition Autoscroll Status Indicator
- this.$autoScrollContainer.css({
- top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET,
- }).show();
- this.$autoScrollStatusText.addClass('animate');
- } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
- gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
- // Build Log height is small
-
- this.$scrollTopBtn.hide();
- this.$scrollBottomBtn.hide();
-
- // Hide Autoscroll Status Indicator
- this.$autoScrollContainer.hide();
- this.$autoScrollStatusText.removeClass('animate');
- }
-
- if (this.buildStatus === 'running' || this.buildStatus === 'pending') {
- // Check if Refresh Animation is in Viewport and enable Autoscroll, disable otherwise.
- this.$autoScrollStatus.data(
- 'state',
- gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)) ? 'enabled' : 'disabled',
- );
- }
+ });
};
Build.prototype.shouldHideSidebarForViewport = function () {
@@ -257,18 +238,24 @@ window.Build = (function () {
};
Build.prototype.toggleSidebar = function (shouldHide) {
- const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined;
+ const shouldShow = !shouldHide;
- this.$buildScroll.toggleClass('sidebar-expanded', shouldShow)
- .toggleClass('sidebar-collapsed', shouldHide);
- this.$truncatedInfo.toggleClass('sidebar-expanded', shouldShow)
+ this.$buildTrace
+ .toggleClass('sidebar-expanded', shouldShow)
.toggleClass('sidebar-collapsed', shouldHide);
- this.$sidebar.toggleClass('right-sidebar-expanded', shouldShow)
+ this.$sidebar
+ .toggleClass('right-sidebar-expanded', shouldShow)
.toggleClass('right-sidebar-collapsed', shouldHide);
};
Build.prototype.sidebarOnResize = function () {
this.toggleSidebar(this.shouldHideSidebarForViewport());
+
+ this.verifyTopPosition();
+
+ if (this.$scrollContainer.getNiceScroll(0)) {
+ this.toggleScroll();
+ }
};
Build.prototype.sidebarOnClick = function () {
@@ -301,24 +288,5 @@ window.Build = (function () {
this.populateJobs(stage);
};
- Build.prototype.stepTrace = function (e) {
- e.preventDefault();
-
- const $currentTarget = $(e.currentTarget);
- $.scrollTo($currentTarget.attr('href'), {
- offset: 0,
- });
- };
-
- Build.prototype.initAffixTruncatedInfo = function () {
- const offsetTop = this.$buildTrace.offset().top;
-
- this.$truncatedInfo.affix({
- offset: {
- top: offsetTop,
- },
- });
- };
-
return Build;
})();
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js
index ad9c600b499..082fbafb740 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js
@@ -1,11 +1,12 @@
import Vue from 'vue';
import Visibility from 'visibilityjs';
-import PipelinesTableComponent from '../../vue_shared/components/pipelines_table';
+import pipelinesTableComponent from '../../vue_shared/components/pipelines_table';
import PipelinesService from '../../pipelines/services/pipelines_service';
import PipelineStore from '../../pipelines/stores/pipelines_store';
import eventHub from '../../pipelines/event_hub';
-import EmptyState from '../../pipelines/components/empty_state.vue';
-import ErrorState from '../../pipelines/components/error_state.vue';
+import emptyState from '../../pipelines/components/empty_state.vue';
+import errorState from '../../pipelines/components/error_state.vue';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import '../../lib/utils/common_utils';
import '../../vue_shared/vue_resource_interceptor';
import Poll from '../../lib/utils/poll';
@@ -17,16 +18,15 @@ import Poll from '../../lib/utils/poll';
* We need a store to store the received environemnts.
* We need a service to communicate with the server.
*
- * Necessary SVG in the table are provided as props. This should be refactored
- * as soon as we have Webpack and can load them directly into JS files.
*/
export default Vue.component('pipelines-table', {
components: {
- 'pipelines-table-component': PipelinesTableComponent,
- 'error-state': ErrorState,
- 'empty-state': EmptyState,
+ pipelinesTableComponent,
+ errorState,
+ emptyState,
+ loadingIcon,
},
/**
@@ -47,6 +47,7 @@ export default Vue.component('pipelines-table', {
hasError: false,
isMakingRequest: false,
updateGraphDropdown: false,
+ hasMadeRequest: false,
};
},
@@ -55,9 +56,15 @@ export default Vue.component('pipelines-table', {
return this.hasError && !this.isLoading;
},
+ /**
+ * Empty state is only rendered if after the first request we receive no pipelines.
+ *
+ * @return {Boolean}
+ */
shouldRenderEmptyState() {
return !this.state.pipelines.length &&
!this.isLoading &&
+ this.hasMadeRequest &&
!this.hasError;
},
@@ -94,6 +101,10 @@ export default Vue.component('pipelines-table', {
if (!Visibility.hidden()) {
this.isLoading = true;
this.poll.makeRequest();
+ } else {
+ // If tab is not visible we need to make the first request so we don't show the empty
+ // state without knowing if there are any pipelines
+ this.fetchPipelines();
}
Visibility.change(() => {
@@ -107,7 +118,7 @@ export default Vue.component('pipelines-table', {
eventHub.$on('refreshPipelines', this.fetchPipelines);
},
- beforeDestroyed() {
+ beforeDestroy() {
eventHub.$off('refreshPipelines');
},
@@ -127,6 +138,8 @@ export default Vue.component('pipelines-table', {
successCallback(resp) {
const response = resp.json();
+ this.hasMadeRequest = true;
+
// depending of the endpoint the response can either bring a `pipelines` key or not.
const pipelines = response.pipelines || response;
this.store.storePipelines(pipelines);
@@ -151,13 +164,12 @@ export default Vue.component('pipelines-table', {
template: `
<div class="content-list pipelines">
- <div
- class="realtime-loading"
- v-if="isLoading">
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- </div>
+
+ <loading-icon
+ label="Loading pipelines"
+ size="3"
+ v-if="isLoading"
+ />
<empty-state
v-if="shouldRenderEmptyState"
diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js
index e3f9eaaf39c..2b0bf49cf92 100644
--- a/app/assets/javascripts/commits.js
+++ b/app/assets/javascripts/commits.js
@@ -7,6 +7,8 @@ window.CommitsList = (function() {
CommitsList.timer = null;
CommitsList.init = function(limit) {
+ this.$contentList = $('.content_list');
+
$("body").on("click", ".day-commits-table li.commit", function(e) {
if (e.target.nodeName !== "A") {
location.href = $(this).attr("url");
@@ -14,9 +16,9 @@ window.CommitsList = (function() {
return false;
}
});
- Pager.init(limit, false, false, function() {
- gl.utils.localTimeAgo($('.js-timeago'));
- });
+
+ Pager.init(limit, false, false, this.processCommits);
+
this.content = $("#commits-list");
this.searchField = $("#commits-search");
this.lastSearch = this.searchField.val();
@@ -62,5 +64,34 @@ window.CommitsList = (function() {
});
};
+ // Prepare loaded data.
+ CommitsList.processCommits = (data) => {
+ let processedData = data;
+ const $processedData = $(processedData);
+ const $commitsHeadersLast = CommitsList.$contentList.find('li.js-commit-header').last();
+ const lastShownDay = $commitsHeadersLast.data('day');
+ const $loadedCommitsHeadersFirst = $processedData.filter('li.js-commit-header').first();
+ const loadedShownDayFirst = $loadedCommitsHeadersFirst.data('day');
+ let commitsCount;
+
+ // If commits headers show the same date,
+ // remove the last header and change the previous one.
+ if (lastShownDay === loadedShownDayFirst) {
+ // Last shown commits count under the last commits header.
+ commitsCount = $commitsHeadersLast.nextUntil('li.js-commit-header').find('li.commit').length;
+
+ // Remove duplicate of commits header.
+ processedData = $processedData.not(`li.js-commit-header[data-day="${loadedShownDayFirst}"]`);
+
+ // Update commits count in the previous commits header.
+ commitsCount += Number($(processedData).nextUntil('li.js-commit-header').first().find('li.commit').length);
+ $commitsHeadersLast.find('span.commits-count').text(`${commitsCount} ${gl.text.pluralize('commit', commitsCount)}`);
+ }
+
+ gl.utils.localTimeAgo($processedData.find('.js-timeago'));
+
+ return processedData;
+ };
+
return CommitsList;
})();
diff --git a/app/assets/javascripts/copy_as_gfm.js b/app/assets/javascripts/copy_as_gfm.js
index 570799c030e..ba9d9a3e1f7 100644
--- a/app/assets/javascripts/copy_as_gfm.js
+++ b/app/assets/javascripts/copy_as_gfm.js
@@ -1,6 +1,6 @@
/* eslint-disable class-methods-use-this, object-shorthand, no-unused-vars, no-use-before-define, no-new, max-len, no-restricted-syntax, guard-for-in, no-continue */
-require('./lib/utils/common_utils');
+import './lib/utils/common_utils';
const gfmRules = {
// The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert
@@ -18,12 +18,12 @@ const gfmRules = {
},
},
TaskListFilter: {
- 'input[type=checkbox].task-list-item-checkbox'(el, text) {
+ 'input[type=checkbox].task-list-item-checkbox'(el) {
return `[${el.checked ? 'x' : ' '}]`;
},
},
ReferenceFilter: {
- '.tooltip'(el, text) {
+ '.tooltip'(el) {
return '';
},
'a.gfm:not([data-link=true])'(el, text) {
@@ -39,15 +39,15 @@ const gfmRules = {
},
},
TableOfContentsFilter: {
- 'ul.section-nav'(el, text) {
+ 'ul.section-nav'(el) {
return '[[_TOC_]]';
},
},
EmojiFilter: {
- 'img.emoji'(el, text) {
+ 'img.emoji'(el) {
return el.getAttribute('alt');
},
- 'gl-emoji'(el, text) {
+ 'gl-emoji'(el) {
return `:${el.getAttribute('data-name')}:`;
},
},
@@ -57,13 +57,13 @@ const gfmRules = {
},
},
VideoLinkFilter: {
- '.video-container'(el, text) {
+ '.video-container'(el) {
const videoEl = el.querySelector('video');
if (!videoEl) return false;
return CopyAsGFM.nodeToGFM(videoEl);
},
- 'video'(el, text) {
+ 'video'(el) {
return `![${el.dataset.title}](${el.getAttribute('src')})`;
},
},
@@ -74,19 +74,19 @@ const gfmRules = {
'code.code.math[data-math-style=inline]'(el, text) {
return `$\`${text}\`$`;
},
- 'span.katex-display span.katex-mathml'(el, text) {
+ 'span.katex-display span.katex-mathml'(el) {
const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
if (!mathAnnotation) return false;
return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``;
},
- 'span.katex-mathml'(el, text) {
+ 'span.katex-mathml'(el) {
const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
if (!mathAnnotation) return false;
return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`;
},
- 'span.katex-html'(el, text) {
+ 'span.katex-html'(el) {
// We don't want to include the content of this element in the copied text.
return '';
},
@@ -95,7 +95,7 @@ const gfmRules = {
},
},
SanitizationFilter: {
- 'a[name]:not([href]):empty'(el, text) {
+ 'a[name]:not([href]):empty'(el) {
return el.outerHTML;
},
'dl'(el, text) {
@@ -143,7 +143,7 @@ const gfmRules = {
},
},
MarkdownFilter: {
- 'br'(el, text) {
+ 'br'(el) {
// Two spaces at the end of a line are turned into a BR
return ' ';
},
@@ -162,7 +162,7 @@ const gfmRules = {
'blockquote'(el, text) {
return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n');
},
- 'img'(el, text) {
+ 'img'(el) {
return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
},
'a.anchor'(el, text) {
@@ -222,10 +222,10 @@ const gfmRules = {
'sup'(el, text) {
return `^${text}`;
},
- 'hr'(el, text) {
+ 'hr'(el) {
return '-----';
},
- 'table'(el, text) {
+ 'table'(el) {
const theadEl = el.querySelector('thead');
const tbodyEl = el.querySelector('tbody');
if (!theadEl || !tbodyEl) return false;
@@ -233,11 +233,11 @@ const gfmRules = {
const theadText = CopyAsGFM.nodeToGFM(theadEl);
const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl);
- return theadText + tbodyText;
+ return [theadText, tbodyText].join('\n');
},
'thead'(el, text) {
const cells = _.map(el.querySelectorAll('th'), (cell) => {
- let chars = CopyAsGFM.nodeToGFM(cell).trim().length + 2;
+ let chars = CopyAsGFM.nodeToGFM(cell).length + 2;
let before = '';
let after = '';
@@ -262,10 +262,15 @@ const gfmRules = {
return before + middle + after;
});
- return `${text}|${cells.join('|')}|`;
+ const separatorRow = `|${cells.join('|')}|`;
+
+ return [text, separatorRow].join('\n');
},
- 'tr'(el, text) {
- const cells = _.map(el.querySelectorAll('td, th'), cell => CopyAsGFM.nodeToGFM(cell).trim());
+ 'tr'(el) {
+ const cellEls = el.querySelectorAll('td, th');
+ if (cellEls.length === 0) return false;
+
+ const cells = _.map(cellEls, cell => CopyAsGFM.nodeToGFM(cell));
return `| ${cells.join(' | ')} |`;
},
},
@@ -273,12 +278,12 @@ const gfmRules = {
class CopyAsGFM {
constructor() {
- $(document).on('copy', '.md, .wiki', (e) => { this.copyAsGFM(e, CopyAsGFM.transformGFMSelection); });
- $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { this.copyAsGFM(e, CopyAsGFM.transformCodeSelection); });
- $(document).on('paste', '.js-gfm-input', this.pasteGFM.bind(this));
+ $(document).on('copy', '.md, .wiki', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformGFMSelection); });
+ $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformCodeSelection); });
+ $(document).on('paste', '.js-gfm-input', CopyAsGFM.pasteGFM);
}
- copyAsGFM(e, transformer) {
+ static copyAsGFM(e, transformer) {
const clipboardData = e.originalEvent.clipboardData;
if (!clipboardData) return;
@@ -292,26 +297,59 @@ class CopyAsGFM {
e.stopPropagation();
clipboardData.setData('text/plain', el.textContent);
- clipboardData.setData('text/x-gfm', CopyAsGFM.nodeToGFM(el));
+ clipboardData.setData('text/x-gfm', this.nodeToGFM(el));
}
- pasteGFM(e) {
+ static pasteGFM(e) {
const clipboardData = e.originalEvent.clipboardData;
if (!clipboardData) return;
+ const text = clipboardData.getData('text/plain');
const gfm = clipboardData.getData('text/x-gfm');
if (!gfm) return;
e.preventDefault();
- window.gl.utils.insertText(e.target, gfm);
+ window.gl.utils.insertText(e.target, (textBefore, textAfter) => {
+ // If the text before the cursor contains an odd number of backticks,
+ // we are either inside an inline code span that starts with 1 backtick
+ // or a code block that starts with 3 backticks.
+ // This logic still holds when there are one or more _closed_ code spans
+ // or blocks that will have 2 or 6 backticks.
+ // This will break down when the actual code block contains an uneven
+ // number of backticks, but this is a rare edge case.
+ const backtickMatch = textBefore.match(/`/g);
+ const insideCodeBlock = backtickMatch && (backtickMatch.length % 2) === 1;
+
+ if (insideCodeBlock) {
+ return text;
+ }
+
+ return gfm;
+ });
}
static transformGFMSelection(documentFragment) {
- // If the documentFragment contains more than just Markdown, don't copy as GFM.
- if (documentFragment.querySelector('.md, .wiki')) return null;
+ const gfmEls = documentFragment.querySelectorAll('.md, .wiki');
+ switch (gfmEls.length) {
+ case 0: {
+ return documentFragment;
+ }
+ case 1: {
+ return gfmEls[0];
+ }
+ default: {
+ const allGfmEl = document.createElement('div');
+
+ for (let i = 0; i < gfmEls.length; i += 1) {
+ const lineEl = gfmEls[i];
+ allGfmEl.appendChild(lineEl);
+ allGfmEl.appendChild(document.createTextNode('\n\n'));
+ }
- return documentFragment;
+ return allGfmEl;
+ }
+ }
}
static transformCodeSelection(documentFragment) {
@@ -343,7 +381,7 @@ class CopyAsGFM {
return codeEl;
}
- static nodeToGFM(node) {
+ static nodeToGFM(node, respectWhitespaceParam = false) {
if (node.nodeType === Node.COMMENT_NODE) {
return '';
}
@@ -352,7 +390,9 @@ class CopyAsGFM {
return node.textContent;
}
- const text = this.innerGFM(node);
+ const respectWhitespace = respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE');
+
+ const text = this.innerGFM(node, respectWhitespace);
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
return text;
@@ -366,7 +406,17 @@ class CopyAsGFM {
if (!window.gl.utils.nodeMatchesSelector(node, selector)) continue;
- const result = func(node, text);
+ let result;
+ if (func.length === 2) {
+ // if `func` takes 2 arguments, it depends on text.
+ // if there is no text, we don't need to generate GFM for this node.
+ if (text.length === 0) continue;
+
+ result = func(node, text);
+ } else {
+ result = func(node);
+ }
+
if (result === false) continue;
return result;
@@ -376,7 +426,7 @@ class CopyAsGFM {
return text;
}
- static innerGFM(parentNode) {
+ static innerGFM(parentNode, respectWhitespace = false) {
const nodes = parentNode.childNodes;
const clonedParentNode = parentNode.cloneNode(true);
@@ -386,13 +436,19 @@ class CopyAsGFM {
const node = nodes[i];
const clonedNode = clonedNodes[i];
- const text = this.nodeToGFM(node);
+ const text = this.nodeToGFM(node, respectWhitespace);
// `clonedNode.replaceWith(text)` is not yet widely supported
clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode);
}
- return clonedParentNode.innerText || clonedParentNode.textContent;
+ let nodeText = clonedParentNode.innerText || clonedParentNode.textContent;
+
+ if (!respectWhitespace) {
+ nodeText = nodeText.trim();
+ }
+
+ return nodeText;
}
}
diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js
index 121d64db789..907b468e576 100644
--- a/app/assets/javascripts/create_label.js
+++ b/app/assets/javascripts/create_label.js
@@ -1,5 +1,5 @@
/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, comma-dangle, prefer-template, quotes, no-param-reassign, wrap-iife, max-len */
-/* global Api */
+import Api from './api';
class CreateLabelDropdown {
constructor ($el, namespacePath, projectPath) {
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 e9a30476945..7c32a38fbe7 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js
@@ -1,6 +1,7 @@
/* eslint-disable no-param-reassign */
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
@@ -10,6 +11,9 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({
items: Array,
stage: Object,
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div>
<div class="events-description">
@@ -19,7 +23,8 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="mergeRequest in items" class="stage-event-item">
<div class="item-details">
- <img class="avatar" :src="mergeRequest.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="mergeRequest.author.avatarUrl"/>
<h5 class="item-title merge-merquest-title">
<a :href="mergeRequest.url">
{{ mergeRequest.title }}
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 3626a9ce943..5f4a0ac8590 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js
@@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */
-
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
@@ -10,6 +10,9 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({
items: Array,
stage: Object,
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div>
<div class="events-description">
@@ -19,7 +22,8 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="issue in items" class="stage-event-item">
<div class="item-details">
- <img class="avatar" :src="issue.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="issue.author.avatarUrl"/>
<h5 class="item-title issue-title">
<a class="issue-title" :href="issue.url">
{{ issue.title }}
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 c99bb8e9a13..11fee5410d9 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js
@@ -1,5 +1,6 @@
/* eslint-disable no-param-reassign */
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
import iconCommit from '../svg/icon_commit.svg';
const global = window.gl || (window.gl = {});
@@ -10,11 +11,12 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({
items: Array,
stage: Object,
},
-
+ components: {
+ userAvatarImage,
+ },
data() {
return { iconCommit };
},
-
template: `
<div>
<div class="events-description">
@@ -24,7 +26,8 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="commit in items" class="stage-event-item">
<div class="item-details item-conmmit-component">
- <img class="avatar" :src="commit.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="commit.author.avatarUrl"/>
<h5 class="item-title commit-title">
<a :href="commit.commitUrl">
{{ commit.title }}
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 779458578ab..b7ba9360f70 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js
@@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */
-
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
@@ -10,6 +10,9 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({
items: Array,
stage: Object,
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div>
<div class="events-description">
@@ -19,7 +22,8 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="issue in items" class="stage-event-item">
<div class="item-details">
- <img class="avatar" :src="issue.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="issue.author.avatarUrl"/>
<h5 class="item-title issue-title">
<a class="issue-title" :href="issue.url">
{{ issue.title }}
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 2b00593561f..f41a0d0e4ff 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js
@@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */
-
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
@@ -10,6 +10,9 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({
items: Array,
stage: Object,
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div>
<div class="events-description">
@@ -19,7 +22,8 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="mergeRequest in items" class="stage-event-item">
<div class="item-details">
- <img class="avatar" :src="mergeRequest.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="mergeRequest.author.avatarUrl"/>
<h5 class="item-title merge-merquest-title">
<a :href="mergeRequest.url">
{{ mergeRequest.title }}
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 acbde35eb55..d7c906c9d39 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js
@@ -1,5 +1,6 @@
/* eslint-disable no-param-reassign */
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
import iconBranch from '../svg/icon_branch.svg';
const global = window.gl || (window.gl = {});
@@ -13,6 +14,9 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({
data() {
return { iconBranch };
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div>
<div class="events-description">
@@ -22,13 +26,14 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="build in items" class="stage-event-item item-build-component">
<div class="item-details">
- <img class="avatar" :src="build.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="build.author.avatarUrl"/>
<h5 class="item-title">
<a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
<i class="fa fa-code-fork"></i>
- <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a>
+ <a :href="build.branch.url" class="ref-name">{{ build.branch.name }}</a>
<span class="icon-branch">${iconBranch}</span>
- <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a>
+ <a :href="build.commitUrl" class="commit-sha">{{ build.shortSha }}</a>
</h5>
<span>
<a :href="build.url" class="build-date">{{ build.date }}</a>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js
index e306026429e..78cc97eea0b 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js
@@ -29,9 +29,9 @@ global.cycleAnalytics.StageTestComponent = Vue.extend({
&middot;
<a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
<i class="fa fa-code-fork"></i>
- <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a>
+ <a :href="build.branch.url" class="ref-name">{{ build.branch.name }}</a>
<span class="icon-branch">${iconBranch}</span>
- <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a>
+ <a :href="build.commitUrl" class="commit-sha">{{ build.shortSha }}</a>
</h5>
<span>
<a :href="build.url" class="issue-date">
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index c8e53cb554e..44791a93936 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -4,18 +4,16 @@ 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');
-require('./components/stage_issue_component');
-require('./components/stage_plan_component');
-require('./components/stage_production_component');
-require('./components/stage_review_component');
-require('./components/stage_staging_component');
-require('./components/stage_test_component');
-require('./components/total_time_component');
-require('./cycle_analytics_service');
-require('./cycle_analytics_store');
-require('./default_event_objects');
+import './components/stage_code_component';
+import './components/stage_issue_component';
+import './components/stage_plan_component';
+import './components/stage_production_component';
+import './components/stage_review_component';
+import './components/stage_staging_component';
+import './components/stage_test_component';
+import './components/total_time_component';
+import './cycle_analytics_service';
+import './cycle_analytics_store';
Vue.use(Translate);
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
index 50bd394e90e..991f8c1f6fd 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
@@ -1,8 +1,8 @@
/* eslint-disable no-param-reassign */
-import { __ } from '../locale';
-require('../lib/utils/text_utility');
-const DEFAULT_EVENT_OBJECTS = require('./default_event_objects');
+import { __ } from '../locale';
+import '../lib/utils/text_utility';
+import DEFAULT_EVENT_OBJECTS from './default_event_objects';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
diff --git a/app/assets/javascripts/cycle_analytics/default_event_objects.js b/app/assets/javascripts/cycle_analytics/default_event_objects.js
index cfaf9835bf8..57f9019d2f8 100644
--- a/app/assets/javascripts/cycle_analytics/default_event_objects.js
+++ b/app/assets/javascripts/cycle_analytics/default_event_objects.js
@@ -1,4 +1,4 @@
-module.exports = {
+export default {
issue: {
created_at: '',
url: '',
diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue
index 3ff3a9d977e..3f993213dd0 100644
--- a/app/assets/javascripts/deploy_keys/components/action_btn.vue
+++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue
@@ -1,5 +1,6 @@
<script>
import eventHub from '../eventhub';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
data() {
@@ -22,6 +23,11 @@
default: 'btn-default',
},
},
+
+ components: {
+ loadingIcon,
+ },
+
methods: {
doAction() {
this.isLoading = true;
@@ -44,11 +50,6 @@
:disabled="isLoading"
@click="doAction">
{{ text }}
- <i
- v-if="isLoading"
- class="fa fa-spinner fa-spin"
- aria-hidden="true"
- aria-label="Loading">
- </i>
+ <loading-icon v-if="isLoading" />
</button>
</template>
diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue
index 7315a9e11cb..a663e30dfd0 100644
--- a/app/assets/javascripts/deploy_keys/components/app.vue
+++ b/app/assets/javascripts/deploy_keys/components/app.vue
@@ -4,6 +4,7 @@
import DeployKeysService from '../service';
import DeployKeysStore from '../store';
import keysPanel from './keys_panel.vue';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
data() {
@@ -28,6 +29,7 @@
},
components: {
keysPanel,
+ loadingIcon,
},
methods: {
fetchKeys() {
@@ -73,30 +75,32 @@
</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 class="append-bottom-default deploy-keys">
+ <loading-icon
+ v-if="isLoading && !hasKeys"
+ size="2"
+ label="Loading deploy keys"
+ />
<div v-else-if="hasKeys">
<keys-panel
title="Enabled deploy keys for this project"
:keys="keys.enabled_keys"
- :store="store" />
+ :store="store"
+ :endpoint="endpoint"
+ />
<keys-panel
title="Deploy keys from projects you have access to"
:keys="keys.available_project_keys"
- :store="store" />
+ :store="store"
+ :endpoint="endpoint"
+ />
<keys-panel
v-if="keys.public_keys.length"
title="Public deploy keys available to any project"
:keys="keys.public_keys"
- :store="store" />
+ :store="store"
+ :endpoint="endpoint"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index 0a06a481b96..904f7f64fa8 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -11,6 +11,10 @@
type: Object,
required: true,
},
+ endpoint: {
+ type: String,
+ required: true,
+ },
},
components: {
actionBtn,
@@ -19,6 +23,9 @@
timeagoDate() {
return gl.utils.getTimeago().format(this.deployKey.created_at);
},
+ editDeployKeyPath() {
+ return `${this.endpoint}/${this.deployKey.id}/edit`;
+ },
},
methods: {
isEnabled(id) {
@@ -33,7 +40,8 @@
<div class="pull-left append-right-10 hidden-xs">
<i
aria-hidden="true"
- class="fa fa-key key-icon">
+ class="fa fa-key key-icon"
+ >
</i>
</div>
<div class="deploy-key-content key-list-item-info">
@@ -45,7 +53,8 @@
</div>
<div
v-if="deployKey.can_push"
- class="write-access-allowed">
+ class="write-access-allowed"
+ >
Write access allowed
</div>
</div>
@@ -53,7 +62,8 @@
<a
v-for="project in deployKey.projects"
class="label deploy-project-label"
- :href="project.full_path">
+ :href="project.full_path"
+ >
{{ project.full_name }}
</a>
</div>
@@ -61,20 +71,30 @@
<span class="key-created-at">
created {{ timeagoDate }}
</span>
+ <a
+ v-if="deployKey.can_edit"
+ class="btn btn-small"
+ :href="editDeployKeyPath"
+ >
+ Edit
+ </a>
<action-btn
v-if="!isEnabled(deployKey.id)"
:deploy-key="deployKey"
- type="enable"/>
+ type="enable"
+ />
<action-btn
v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned"
:deploy-key="deployKey"
btn-css-class="btn-warning"
- type="remove" />
+ type="remove"
+ />
<action-btn
v-else
:deploy-key="deployKey"
btn-css-class="btn-warning"
- type="disable" />
+ 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
index eccc470578b..9e6fb244af6 100644
--- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue
+++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
@@ -20,6 +20,10 @@
type: Object,
required: true,
},
+ endpoint: {
+ type: String,
+ required: true,
+ },
},
components: {
key,
@@ -34,18 +38,22 @@
({{ keys.length }})
</h5>
<ul class="well-list"
- v-if="keys.length">
+ v-if="keys.length"
+ >
<li
v-for="deployKey in keys"
:key="deployKey.id">
<key
:deploy-key="deployKey"
- :store="store" />
+ :store="store"
+ :endpoint="endpoint"
+ />
</li>
</ul>
<div
class="settings-message text-center"
- v-else-if="showHelpBox">
+ v-else-if="showHelpBox"
+ >
No deploy keys found. Create one with the form above.
</div>
</div>
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index 5aa3eb46a69..725ec7b9c70 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -1,6 +1,6 @@
/* eslint-disable class-methods-use-this */
-require('./lib/utils/url_utility');
+import './lib/utils/url_utility';
const UNFOLD_COUNT = 20;
let isBound = false;
diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
index 5f533b5761c..517bdb6be09 100644
--- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
+++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
@@ -3,6 +3,7 @@
import Vue from 'vue';
import collapseIcon from '../icons/collapse_icon.svg';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const DiffNoteAvatars = Vue.extend({
props: ['discussionId'],
@@ -15,22 +16,24 @@ const DiffNoteAvatars = Vue.extend({
collapseIcon,
};
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div class="diff-comment-avatar-holders"
v-show="notesCount !== 0">
<div v-if="!isVisible">
- <img v-for="note in notesSubset"
- class="avatar diff-comment-avatar has-tooltip js-diff-comment-avatar"
- width="19"
- height="19"
- role="button"
- data-container="body"
- data-placement="top"
- data-html="true"
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image
+ v-for="note in notesSubset"
+ class="diff-comment-avatar js-diff-comment-avatar"
+ @click.native="clickedAvatar($event)"
+ :img-src="note.authorAvatar"
+ :tooltip-text="getTooltipText(note)"
:data-line-type="lineType"
- :title="note.authorName + ': ' + note.noteTruncated"
- :src="note.authorAvatar"
- @click="clickedAvatar($event)" />
+ :size="19"
+ data-html="true"
+ />
<span v-if="notesCount > shownAvatars"
class="diff-comments-more-count has-tooltip js-diff-comment-avatar"
data-container="body"
@@ -150,6 +153,9 @@ const DiffNoteAvatars = Vue.extend({
setDiscussionVisible() {
this.isVisible = $(`.diffs .notes[data-discussion-id="${this.discussion.id}"]`).is(':visible');
},
+ getTooltipText(note) {
+ return `${note.authorName}: ${note.noteTruncated}`;
+ },
},
});
diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
index 8a0fd3bb4a7..37ddca29e71 100644
--- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
+++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
@@ -16,6 +16,13 @@ const JumpToDiscussion = Vue.extend({
};
},
computed: {
+ buttonText: function () {
+ if (this.discussionId) {
+ return 'Jump to next unresolved discussion';
+ } else {
+ return 'Jump to first unresolved discussion';
+ }
+ },
allResolved: function () {
return this.unresolvedDiscussionCount === 0;
},
diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
index fdd27534e0e..a2d33b0936e 100644
--- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js
+++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
@@ -2,19 +2,18 @@
/* global ResolveCount */
import Vue from 'vue';
-
-require('./models/discussion');
-require('./models/note');
-require('./stores/comments');
-require('./services/resolve');
-require('./mixins/discussion');
-require('./components/comment_resolve_btn');
-require('./components/jump_to_discussion');
-require('./components/resolve_btn');
-require('./components/resolve_count');
-require('./components/resolve_discussion_btn');
-require('./components/diff_note_avatars');
-require('./components/new_issue_for_discussion');
+import './models/discussion';
+import './models/note';
+import './stores/comments';
+import './services/resolve';
+import './mixins/discussion';
+import './components/comment_resolve_btn';
+import './components/jump_to_discussion';
+import './components/resolve_btn';
+import './components/resolve_count';
+import './components/resolve_discussion_btn';
+import './components/diff_note_avatars';
+import './components/new_issue_for_discussion';
$(() => {
const projectPath = document.querySelector('.merge-request').dataset.projectPath;
diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js
index ba4f6d36fcb..807ab11d292 100644
--- a/app/assets/javascripts/diff_notes/services/resolve.js
+++ b/app/assets/javascripts/diff_notes/services/resolve.js
@@ -3,11 +3,7 @@
/* global CommentsStore */
import Vue from 'vue';
-import VueResource from 'vue-resource';
-
-require('../../vue_shared/vue_resource_interceptor');
-
-Vue.use(VueResource);
+import '../../vue_shared/vue_resource_interceptor';
window.gl = window.gl || {};
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index a2b2ddfefc8..0c420c12345 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -3,7 +3,7 @@
/* global ActiveTabMemoizer */
/* global ShortcutsNavigation */
/* global Build */
-/* global Issuable */
+/* global IssuableIndex */
/* global ShortcutsIssuable */
/* global ZenMode */
/* global Milestone */
@@ -14,7 +14,6 @@
/* global NotificationsForm */
/* global TreeView */
/* global NotificationsDropdown */
-/* global UsersSelect */
/* global GroupAvatar */
/* global LineHighlighter */
/* global ProjectFork */
@@ -35,12 +34,13 @@
/* global ShortcutsWiki */
import Issue from './issue';
-
import BindInOut from './behaviors/bind_in_out';
+import DeleteModal from './branches/branches_delete_modal';
import Group from './group';
import GroupName from './group_name';
import GroupsList from './groups_list';
import ProjectsList from './projects_list';
+import setupProjectEdit from './project_edit';
import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater';
import Landing from './landing';
@@ -51,9 +51,11 @@ import ShortcutsWiki from './shortcuts_wiki';
import Pipelines from './pipelines';
import BlobViewer from './blob/viewer/index';
import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
+import UsersSelect from './users_select';
import RefSelectDropdown from './ref_select_dropdown';
-
-const ShortcutsBlob = require('./shortcuts_blob');
+import GfmAutoComplete from './gfm_auto_complete';
+import ShortcutsBlob from './shortcuts_blob';
+import initSettingsPanels from './settings_panels';
(function() {
var Dispatcher;
@@ -78,6 +80,8 @@ const ShortcutsBlob = require('./shortcuts_blob');
path = page.split(':');
shortcut_handler = null;
+ new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup();
+
function initBlob() {
new LineHighlighter();
@@ -113,20 +117,22 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'projects:boards:show':
case 'projects:boards:index':
shortcut_handler = new ShortcutsNavigation();
+ new UsersSelect();
break;
- case 'projects:builds:show':
+ case 'projects:jobs:show':
new Build();
break;
case 'projects:merge_requests:index':
case 'projects:issues:index':
- if (gl.FilteredSearchManager) {
- new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
+ if (gl.FilteredSearchManager && document.querySelector('.filtered-search')) {
+ const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
+ filteredSearchManager.setup();
}
- Issuable.init();
- new gl.IssuableBulkActions({
- prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_',
- });
+ const pagePrefix = page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_';
+ IssuableIndex.init(pagePrefix);
+
shortcut_handler = new ShortcutsNavigation();
+ new UsersSelect();
break;
case 'projects:issues:show':
new Issue();
@@ -139,6 +145,10 @@ const ShortcutsBlob = require('./shortcuts_blob');
new Milestone();
new Sidebar();
break;
+ case 'groups:issues':
+ case 'groups:merge_requests':
+ new UsersSelect();
+ break;
case 'dashboard:todos:index':
new gl.Todos();
break;
@@ -180,6 +190,7 @@ const ShortcutsBlob = require('./shortcuts_blob');
break;
case 'projects:branches:index':
gl.AjaxLoadingSpinner.init();
+ new DeleteModal();
break;
case 'projects:issues:new':
case 'projects:issues:edit':
@@ -207,6 +218,16 @@ const ShortcutsBlob = require('./shortcuts_blob');
new gl.GLForm($('.tag-form'));
new RefSelectDropdown($('.js-branch-select'), window.gl.availableRefs);
break;
+ case 'projects:snippets:new':
+ case 'projects:snippets:edit':
+ case 'projects:snippets:create':
+ case 'projects:snippets:update':
+ case 'snippets:new':
+ case 'snippets:edit':
+ case 'snippets:create':
+ case 'snippets:update':
+ new gl.GLForm($('.snippet-form'));
+ break;
case 'projects:releases:edit':
new ZenMode();
new gl.GLForm($('.release-form'));
@@ -223,6 +244,10 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'dashboard:activity':
new gl.Activities();
break;
+ case 'dashboard:issues':
+ case 'dashboard:merge_requests':
+ new UsersSelect();
+ break;
case 'projects:commit:show':
new Commit();
new gl.Diff();
@@ -247,6 +272,12 @@ const ShortcutsBlob = require('./shortcuts_blob');
if ($('#tree-slider').length) {
new TreeView();
}
+ if ($('.blob-viewer').length) {
+ new BlobViewer();
+ }
+ break;
+ case 'projects:edit':
+ setupProjectEdit();
break;
case 'projects:pipelines:builds':
case 'projects:pipelines:failures':
@@ -300,6 +331,7 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'projects:tree:show':
shortcut_handler = new ShortcutsNavigation();
new TreeView();
+ new BlobViewer();
gl.TargetBranchDropDown.bootstrap();
break;
case 'projects:find_file:show':
@@ -360,6 +392,8 @@ const ShortcutsBlob = require('./shortcuts_blob');
// Initialize Protected Tag Settings
new ProtectedTagCreate();
new ProtectedTagEditList();
+ // Initialize expandable settings panels
+ initSettingsPanels();
break;
case 'projects:ci_cd:show':
new gl.ProjectVariables();
@@ -371,10 +405,16 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'users:show':
new UserCallout();
break;
+ case 'admin:conversational_development_index:show':
+ new UserCallout();
+ break;
case 'snippets:show':
new LineHighlighter();
new BlobViewer();
break;
+ case 'import:fogbugz:new_user_map':
+ new UsersSelect();
+ break;
}
switch (path.first()) {
case 'sessions':
diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js
index de3927d683c..70cd337fb8a 100644
--- a/app/assets/javascripts/droplab/drop_down.js
+++ b/app/assets/javascripts/droplab/drop_down.js
@@ -1,44 +1,42 @@
-/* eslint-disable */
-
import utils from './utils';
import { SELECTED_CLASS, IGNORE_CLASS } from './constants';
-var DropDown = function(list) {
- this.currentIndex = 0;
- this.hidden = true;
- this.list = typeof list === 'string' ? document.querySelector(list) : list;
- this.items = [];
+class DropDown {
+ constructor(list) {
+ this.currentIndex = 0;
+ this.hidden = true;
+ this.list = typeof list === 'string' ? document.querySelector(list) : list;
+ this.items = [];
- this.eventWrapper = {};
+ this.eventWrapper = {};
- this.getItems();
- this.initTemplateString();
- this.addEvents();
+ this.getItems();
+ this.initTemplateString();
+ this.addEvents();
- this.initialState = list.innerHTML;
-};
+ this.initialState = list.innerHTML;
+ }
-Object.assign(DropDown.prototype, {
- getItems: function() {
+ getItems() {
this.items = [].slice.call(this.list.querySelectorAll('li'));
return this.items;
- },
+ }
- initTemplateString: function() {
- var items = this.items || this.getItems();
+ initTemplateString() {
+ const items = this.items || this.getItems();
- var templateString = '';
+ let templateString = '';
if (items.length > 0) templateString = items[items.length - 1].outerHTML;
this.templateString = templateString;
return this.templateString;
- },
+ }
- clickEvent: function(e) {
+ clickEvent(e) {
if (e.target.tagName === 'UL') return;
if (e.target.classList.contains(IGNORE_CLASS)) return;
- var selected = utils.closest(e.target, 'LI');
+ const selected = utils.closest(e.target, 'LI');
if (!selected) return;
this.addSelectedClass(selected);
@@ -46,95 +44,95 @@ Object.assign(DropDown.prototype, {
e.preventDefault();
this.hide();
- var listEvent = new CustomEvent('click.dl', {
+ const listEvent = new CustomEvent('click.dl', {
detail: {
list: this,
- selected: selected,
+ selected,
data: e.target.dataset,
},
});
this.list.dispatchEvent(listEvent);
- },
+ }
- addSelectedClass: function (selected) {
+ addSelectedClass(selected) {
this.removeSelectedClasses();
selected.classList.add(SELECTED_CLASS);
- },
+ }
- removeSelectedClasses: function () {
+ removeSelectedClasses() {
const items = this.items || this.getItems();
items.forEach(item => item.classList.remove(SELECTED_CLASS));
- },
+ }
- addEvents: function() {
- this.eventWrapper.clickEvent = this.clickEvent.bind(this)
+ addEvents() {
+ this.eventWrapper.clickEvent = this.clickEvent.bind(this);
this.list.addEventListener('click', this.eventWrapper.clickEvent);
- },
-
- toggle: function() {
- this.hidden ? this.show() : this.hide();
- },
+ }
- setData: function(data) {
+ setData(data) {
this.data = data;
this.render(data);
- },
+ }
- addData: function(data) {
+ addData(data) {
this.data = (this.data || []).concat(data);
this.render(this.data);
- },
+ }
- render: function(data) {
+ render(data) {
const children = data ? data.map(this.renderChildren.bind(this)) : [];
const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list;
renderableList.innerHTML = children.join('');
- },
+ }
- renderChildren: function(data) {
- var html = utils.template(this.templateString, data);
- var template = document.createElement('div');
+ renderChildren(data) {
+ const html = utils.template(this.templateString, data);
+ const template = document.createElement('div');
template.innerHTML = html;
- this.setImagesSrc(template);
+ DropDown.setImagesSrc(template);
template.firstChild.style.display = data.droplab_hidden ? 'none' : 'block';
return template.firstChild.outerHTML;
- },
-
- setImagesSrc: function(template) {
- const images = [].slice.call(template.querySelectorAll('img[data-src]'));
-
- images.forEach((image) => {
- image.src = image.getAttribute('data-src');
- image.removeAttribute('data-src');
- });
- },
+ }
- show: function() {
+ show() {
if (!this.hidden) return;
this.list.style.display = 'block';
this.currentIndex = 0;
this.hidden = false;
- },
+ }
- hide: function() {
+ hide() {
if (this.hidden) return;
this.list.style.display = 'none';
this.currentIndex = 0;
this.hidden = true;
- },
+ }
- toggle: function () {
- this.hidden ? this.show() : this.hide();
- },
+ toggle() {
+ if (this.hidden) return this.show();
- destroy: function() {
+ return this.hide();
+ }
+
+ destroy() {
this.hide();
this.list.removeEventListener('click', this.eventWrapper.clickEvent);
}
-});
+
+ static setImagesSrc(template) {
+ const images = [...template.querySelectorAll('img[data-src]')];
+
+ images.forEach((image) => {
+ const img = image;
+
+ img.src = img.getAttribute('data-src');
+ img.removeAttribute('data-src');
+ });
+ }
+}
export default DropDown;
diff --git a/app/assets/javascripts/droplab/drop_lab.js b/app/assets/javascripts/droplab/drop_lab.js
index 6eb9f314af7..2a02ede72bf 100644
--- a/app/assets/javascripts/droplab/drop_lab.js
+++ b/app/assets/javascripts/droplab/drop_lab.js
@@ -1,99 +1,99 @@
-/* eslint-disable */
-
import HookButton from './hook_button';
import HookInput from './hook_input';
import utils from './utils';
import Keyboard from './keyboard';
import { DATA_TRIGGER } from './constants';
-var DropLab = function() {
- this.ready = false;
- this.hooks = [];
- this.queuedData = [];
- this.config = {};
+class DropLab {
+ constructor() {
+ this.ready = false;
+ this.hooks = [];
+ this.queuedData = [];
+ this.config = {};
- this.eventWrapper = {};
-};
+ this.eventWrapper = {};
+ }
-Object.assign(DropLab.prototype, {
- loadStatic: function(){
- var dropdownTriggers = [].slice.apply(document.querySelectorAll(`[${DATA_TRIGGER}]`));
+ loadStatic() {
+ const dropdownTriggers = [].slice.apply(document.querySelectorAll(`[${DATA_TRIGGER}]`));
this.addHooks(dropdownTriggers);
- },
+ }
- addData: function () {
- var args = [].slice.apply(arguments);
- this.applyArgs(args, '_addData');
- },
+ addData(...args) {
+ this.applyArgs(args, 'processAddData');
+ }
- setData: function() {
- var args = [].slice.apply(arguments);
- this.applyArgs(args, '_setData');
- },
+ setData(...args) {
+ this.applyArgs(args, 'processSetData');
+ }
- destroy: function() {
+ destroy() {
this.hooks.forEach(hook => hook.destroy());
this.hooks = [];
this.removeEvents();
- },
+ }
- applyArgs: function(args, methodName) {
- if (this.ready) return this[methodName].apply(this, args);
+ applyArgs(args, methodName) {
+ if (this.ready) return this[methodName](...args);
this.queuedData = this.queuedData || [];
this.queuedData.push(args);
- },
- _addData: function(trigger, data) {
- this._processData(trigger, data, 'addData');
- },
+ return this.ready;
+ }
+
+ processAddData(trigger, data) {
+ this.processData(trigger, data, 'addData');
+ }
- _setData: function(trigger, data) {
- this._processData(trigger, data, 'setData');
- },
+ processSetData(trigger, data) {
+ this.processData(trigger, data, 'setData');
+ }
- _processData: function(trigger, data, methodName) {
+ processData(trigger, data, methodName) {
this.hooks.forEach((hook) => {
if (Array.isArray(trigger)) hook.list[methodName](trigger);
if (hook.trigger.id === trigger) hook.list[methodName](data);
});
- },
+ }
- addEvents: function() {
- this.eventWrapper.documentClicked = this.documentClicked.bind(this)
+ addEvents() {
+ this.eventWrapper.documentClicked = this.documentClicked.bind(this);
document.addEventListener('click', this.eventWrapper.documentClicked);
- },
+ }
- documentClicked: function(e) {
+ documentClicked(e) {
let thisTag = e.target;
if (thisTag.tagName !== 'UL') thisTag = utils.closest(thisTag, 'UL');
- if (utils.isDropDownParts(thisTag, this.hooks) || utils.isDropDownParts(e.target, this.hooks)) return;
+ if (utils.isDropDownParts(thisTag, this.hooks)) return;
+ if (utils.isDropDownParts(e.target, this.hooks)) return;
this.hooks.forEach(hook => hook.list.hide());
- },
+ }
- removeEvents: function(){
+ removeEvents() {
document.removeEventListener('click', this.eventWrapper.documentClicked);
- },
-
- changeHookList: function(trigger, list, plugins, config) {
- const availableTrigger = typeof trigger === 'string' ? document.getElementById(trigger) : trigger;
+ }
+ changeHookList(trigger, list, plugins, config) {
+ const availableTrigger = typeof trigger === 'string' ? document.getElementById(trigger) : trigger;
this.hooks.forEach((hook, i) => {
- hook.list.list.dataset.dropdownActive = false;
+ const aHook = hook;
+
+ aHook.list.list.dataset.dropdownActive = false;
- if (hook.trigger !== availableTrigger) return;
+ if (aHook.trigger !== availableTrigger) return;
- hook.destroy();
+ aHook.destroy();
this.hooks.splice(i, 1);
this.addHook(availableTrigger, list, plugins, config);
});
- },
+ }
- addHook: function(hook, list, plugins, config) {
+ addHook(hook, list, plugins, config) {
const availableHook = typeof hook === 'string' ? document.querySelector(hook) : hook;
let availableList;
@@ -111,18 +111,18 @@ Object.assign(DropLab.prototype, {
this.hooks.push(new HookObject(availableHook, availableList, plugins, config));
return this;
- },
+ }
- addHooks: function(hooks, plugins, config) {
+ addHooks(hooks, plugins, config) {
hooks.forEach(hook => this.addHook(hook, null, plugins, config));
return this;
- },
+ }
- setConfig: function(obj){
+ setConfig(obj) {
this.config = obj;
- },
+ }
- fireReady: function() {
+ fireReady() {
const readyEvent = new CustomEvent('ready.dl', {
detail: {
dropdown: this,
@@ -131,10 +131,14 @@ Object.assign(DropLab.prototype, {
document.dispatchEvent(readyEvent);
this.ready = true;
- },
+ }
- init: function (hook, list, plugins, config) {
- hook ? this.addHook(hook, list, plugins, config) : this.loadStatic();
+ init(hook, list, plugins, config) {
+ if (hook) {
+ this.addHook(hook, list, plugins, config);
+ } else {
+ this.loadStatic();
+ }
this.addEvents();
@@ -146,7 +150,7 @@ Object.assign(DropLab.prototype, {
this.queuedData = [];
return this;
- },
-});
+ }
+}
export default DropLab;
diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/droplab/hook.js
index 2f840083571..cf78165b0d8 100644
--- a/app/assets/javascripts/droplab/hook.js
+++ b/app/assets/javascripts/droplab/hook.js
@@ -1,22 +1,15 @@
-/* eslint-disable */
-
import DropDown from './drop_down';
-var Hook = function(trigger, list, plugins, config){
- this.trigger = trigger;
- this.list = new DropDown(list);
- this.type = 'Hook';
- this.event = 'click';
- this.plugins = plugins || [];
- this.config = config || {};
- this.id = trigger.id;
-};
-
-Object.assign(Hook.prototype, {
-
- addEvents: function(){},
-
- constructor: Hook,
-});
+class Hook {
+ constructor(trigger, list, plugins, config) {
+ this.trigger = trigger;
+ this.list = new DropDown(list);
+ this.type = 'Hook';
+ this.event = 'click';
+ this.plugins = plugins || [];
+ this.config = config || {};
+ this.id = trigger.id;
+ }
+}
export default Hook;
diff --git a/app/assets/javascripts/droplab/hook_button.js b/app/assets/javascripts/droplab/hook_button.js
index be8aead1303..af45eba74e7 100644
--- a/app/assets/javascripts/droplab/hook_button.js
+++ b/app/assets/javascripts/droplab/hook_button.js
@@ -1,65 +1,58 @@
-/* eslint-disable */
-
import Hook from './hook';
-var HookButton = function(trigger, list, plugins, config) {
- Hook.call(this, trigger, list, plugins, config);
-
- this.type = 'button';
- this.event = 'click';
+class HookButton extends Hook {
+ constructor(trigger, list, plugins, config) {
+ super(trigger, list, plugins, config);
- this.eventWrapper = {};
+ this.type = 'button';
+ this.event = 'click';
- this.addEvents();
- this.addPlugins();
-};
+ this.eventWrapper = {};
-HookButton.prototype = Object.create(Hook.prototype);
+ this.addEvents();
+ this.addPlugins();
+ }
-Object.assign(HookButton.prototype, {
- addPlugins: function() {
+ addPlugins() {
this.plugins.forEach(plugin => plugin.init(this));
- },
+ }
- clicked: function(e){
- var buttonEvent = new CustomEvent('click.dl', {
+ clicked(e) {
+ const buttonEvent = new CustomEvent('click.dl', {
detail: {
hook: this,
},
bubbles: true,
- cancelable: true
+ cancelable: true,
});
e.target.dispatchEvent(buttonEvent);
this.list.toggle();
- },
+ }
- addEvents: function(){
+ addEvents() {
this.eventWrapper.clicked = this.clicked.bind(this);
this.trigger.addEventListener('click', this.eventWrapper.clicked);
- },
+ }
- removeEvents: function(){
+ removeEvents() {
this.trigger.removeEventListener('click', this.eventWrapper.clicked);
- },
+ }
- restoreInitialState: function() {
+ restoreInitialState() {
this.list.list.innerHTML = this.list.initialState;
- },
+ }
- removePlugins: function() {
+ removePlugins() {
this.plugins.forEach(plugin => plugin.destroy());
- },
+ }
- destroy: function() {
+ destroy() {
this.restoreInitialState();
this.removeEvents();
this.removePlugins();
- },
-
- constructor: HookButton,
-});
-
+ }
+}
export default HookButton;
diff --git a/app/assets/javascripts/droplab/hook_input.js b/app/assets/javascripts/droplab/hook_input.js
index 05082334045..19131a64f2c 100644
--- a/app/assets/javascripts/droplab/hook_input.js
+++ b/app/assets/javascripts/droplab/hook_input.js
@@ -1,25 +1,23 @@
-/* eslint-disable */
-
import Hook from './hook';
-var HookInput = function(trigger, list, plugins, config) {
- Hook.call(this, trigger, list, plugins, config);
+class HookInput extends Hook {
+ constructor(trigger, list, plugins, config) {
+ super(trigger, list, plugins, config);
- this.type = 'input';
- this.event = 'input';
+ this.type = 'input';
+ this.event = 'input';
- this.eventWrapper = {};
+ this.eventWrapper = {};
- this.addEvents();
- this.addPlugins();
-};
+ this.addEvents();
+ this.addPlugins();
+ }
-Object.assign(HookInput.prototype, {
- addPlugins: function() {
+ addPlugins() {
this.plugins.forEach(plugin => plugin.init(this));
- },
+ }
- addEvents: function(){
+ addEvents() {
this.eventWrapper.mousedown = this.mousedown.bind(this);
this.eventWrapper.input = this.input.bind(this);
this.eventWrapper.keyup = this.keyup.bind(this);
@@ -29,19 +27,19 @@ Object.assign(HookInput.prototype, {
this.trigger.addEventListener('input', this.eventWrapper.input);
this.trigger.addEventListener('keyup', this.eventWrapper.keyup);
this.trigger.addEventListener('keydown', this.eventWrapper.keydown);
- },
+ }
- removeEvents: function() {
+ removeEvents() {
this.hasRemovedEvents = true;
this.trigger.removeEventListener('mousedown', this.eventWrapper.mousedown);
this.trigger.removeEventListener('input', this.eventWrapper.input);
this.trigger.removeEventListener('keyup', this.eventWrapper.keyup);
this.trigger.removeEventListener('keydown', this.eventWrapper.keydown);
- },
+ }
- input: function(e) {
- if(this.hasRemovedEvents) return;
+ input(e) {
+ if (this.hasRemovedEvents) return;
this.list.show();
@@ -51,12 +49,12 @@ Object.assign(HookInput.prototype, {
text: e.target.value,
},
bubbles: true,
- cancelable: true
+ cancelable: true,
});
e.target.dispatchEvent(inputEvent);
- },
+ }
- mousedown: function(e) {
+ mousedown(e) {
if (this.hasRemovedEvents) return;
const mouseEvent = new CustomEvent('mousedown.dl', {
@@ -68,21 +66,21 @@ Object.assign(HookInput.prototype, {
cancelable: true,
});
e.target.dispatchEvent(mouseEvent);
- },
+ }
- keyup: function(e) {
+ keyup(e) {
if (this.hasRemovedEvents) return;
this.keyEvent(e, 'keyup.dl');
- },
+ }
- keydown: function(e) {
+ keydown(e) {
if (this.hasRemovedEvents) return;
this.keyEvent(e, 'keydown.dl');
- },
+ }
- keyEvent: function(e, eventName) {
+ keyEvent(e, eventName) {
this.list.show();
const keyEvent = new CustomEvent(eventName, {
@@ -96,17 +94,17 @@ Object.assign(HookInput.prototype, {
cancelable: true,
});
e.target.dispatchEvent(keyEvent);
- },
+ }
- restoreInitialState: function() {
+ restoreInitialState() {
this.list.list.innerHTML = this.list.initialState;
- },
+ }
- removePlugins: function() {
+ removePlugins() {
this.plugins.forEach(plugin => plugin.destroy());
- },
+ }
- destroy: function() {
+ destroy() {
this.restoreInitialState();
this.removeEvents();
@@ -114,6 +112,6 @@ Object.assign(HookInput.prototype, {
this.list.destroy();
}
-});
+}
export default HookInput;
diff --git a/app/assets/javascripts/droplab/keyboard.js b/app/assets/javascripts/droplab/keyboard.js
index 36740a430e1..02f1b805ce4 100644
--- a/app/assets/javascripts/droplab/keyboard.js
+++ b/app/assets/javascripts/droplab/keyboard.js
@@ -8,7 +8,7 @@ const Keyboard = function () {
var isUpArrow = false;
var isDownArrow = false;
var removeHighlight = function removeHighlight(list) {
- var itemElements = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider)'), 0);
+ var itemElements = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider):not(.hidden)'), 0);
var listItems = [];
for(var i = 0; i < itemElements.length; i++) {
var listItem = itemElements[i];
diff --git a/app/assets/javascripts/droplab/plugins/ajax.js b/app/assets/javascripts/droplab/plugins/ajax.js
index 12afe53ed76..c0da5866139 100644
--- a/app/assets/javascripts/droplab/plugins/ajax.js
+++ b/app/assets/javascripts/droplab/plugins/ajax.js
@@ -1,25 +1,8 @@
/* eslint-disable */
+import AjaxCache from '~/lib/utils/ajax_cache';
+
const Ajax = {
- _loadUrlData: function _loadUrlData(url) {
- var self = this;
- return new Promise(function(resolve, reject) {
- var xhr = new XMLHttpRequest;
- xhr.open('GET', url, true);
- xhr.onreadystatechange = function () {
- if(xhr.readyState === XMLHttpRequest.DONE) {
- if (xhr.status === 200) {
- var data = JSON.parse(xhr.responseText);
- self.cache[url] = data;
- return resolve(data);
- } else {
- return reject([xhr.responseText, xhr.status]);
- }
- }
- };
- xhr.send();
- });
- },
_loadData: function _loadData(data, config, self) {
if (config.loadingTemplate) {
var dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]');
@@ -31,7 +14,6 @@ const Ajax = {
init: function init(hook) {
var self = this;
self.destroyed = false;
- self.cache = self.cache || {};
var config = hook.config.Ajax;
this.hook = hook;
if (!config || !config.endpoint || !config.method) {
@@ -48,14 +30,10 @@ const Ajax = {
this.listTemplate = dynamicList.outerHTML;
dynamicList.outerHTML = loadingTemplate.outerHTML;
}
- if (self.cache[config.endpoint]) {
- self._loadData(self.cache[config.endpoint], config, self);
- } else {
- this._loadUrlData(config.endpoint)
- .then(function(d) {
- self._loadData(d, config, self);
- }, config.onError).catch(config.onError);
- }
+
+ AjaxCache.retrieve(config.endpoint)
+ .then((data) => self._loadData(data, config, self))
+ .catch(config.onError);
},
destroy: function() {
this.destroyed = true;
diff --git a/app/assets/javascripts/droplab/plugins/ajax_filter.js b/app/assets/javascripts/droplab/plugins/ajax_filter.js
index cfd7e2ca189..1db20227a16 100644
--- a/app/assets/javascripts/droplab/plugins/ajax_filter.js
+++ b/app/assets/javascripts/droplab/plugins/ajax_filter.js
@@ -1,4 +1,5 @@
/* eslint-disable */
+import AjaxCache from '../../lib/utils/ajax_cache';
const AjaxFilter = {
init: function(hook) {
@@ -58,50 +59,27 @@ const AjaxFilter = {
this.loading = true;
var params = config.params || {};
params[config.searchKey] = searchValue;
- var self = this;
- self.cache = self.cache || {};
var url = config.endpoint + this.buildParams(params);
- var urlCachedData = self.cache[url];
- if (urlCachedData) {
- self._loadData(urlCachedData, config, self);
- } else {
- this._loadUrlData(url)
- .then(function(data) {
- self._loadData(data, config, self);
- }, config.onError).catch(config.onError);
- }
- },
-
- _loadUrlData: function _loadUrlData(url) {
- var self = this;
- return new Promise(function(resolve, reject) {
- var xhr = new XMLHttpRequest;
- xhr.open('GET', url, true);
- xhr.onreadystatechange = function () {
- if(xhr.readyState === XMLHttpRequest.DONE) {
- if (xhr.status === 200) {
- var data = JSON.parse(xhr.responseText);
- self.cache[url] = data;
- return resolve(data);
- } else {
- return reject([xhr.responseText, xhr.status]);
- }
+ return AjaxCache.retrieve(url)
+ .then((data) => {
+ this._loadData(data, config);
+ if (config.onLoadingFinished) {
+ config.onLoadingFinished(data);
}
- };
- xhr.send();
- });
+ })
+ .catch(config.onError);
},
- _loadData: function _loadData(data, config, self) {
- const list = self.hook.list;
+ _loadData(data, config) {
+ const list = this.hook.list;
if (config.loadingTemplate && list.data === undefined ||
list.data.length === 0) {
const dataLoadingTemplate = list.list.querySelector('[data-loading-template]');
if (dataLoadingTemplate) {
- dataLoadingTemplate.outerHTML = self.listTemplate;
+ dataLoadingTemplate.outerHTML = this.listTemplate;
}
}
- if (!self.destroyed) {
+ if (!this.destroyed) {
var hookListChildren = list.list.children;
var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic');
if (onlyDynamicList && data.length === 0) {
@@ -109,7 +87,7 @@ const AjaxFilter = {
}
list.setData.call(list, data);
}
- self.notLoading();
+ this.notLoading();
list.currentIndex = 0;
},
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index b3a76fbb43e..98ddcc20036 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -1,108 +1,159 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, max-len, one-var, no-var, one-var-declaration-per-line, no-unused-vars, camelcase, quotes, no-useless-concat, prefer-template, quote-props, comma-dangle, object-shorthand, consistent-return, prefer-arrow-callback */
/* global Dropzone */
-require('./preview_markdown');
+import './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, uploads_path, showError, showSpinner, uploadFile, uploadProgress;
+ var updateAttachingMessage, $attachingFileMessage, $mdArea, $attachButton, $cancelButton, $retryLink, $uploadingErrorContainer, $uploadingErrorMessage, $uploadProgress, $uploadingProgressContainer, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divHover, divSpinner, dropzone, $formDropzone, formTextarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, maxFileSize, pasteText, uploadsPath, showError, showSpinner, uploadFile, addFileToForm;
Dropzone.autoDiscover = false;
- alertClass = "alert alert-danger alert-dismissable div-dropzone-alert";
- alertAttr = "class=\"close\" data-dismiss=\"alert\"" + "aria-hidden=\"true\"";
- divHover = "<div class=\"div-dropzone-hover\"></div>";
- divSpinner = "<div class=\"div-dropzone-spinner\"></div>";
- divAlert = "<div class=\"" + alertClass + "\"></div>";
- iconPaperclip = "<i class=\"fa fa-paperclip div-dropzone-icon\"></i>";
- 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>";
- 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>");
- form_textarea.on('paste', (function(_this) {
+ divHover = '<div class="div-dropzone-hover"></div>';
+ iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>';
+ $attachButton = form.find('.button-attach-file');
+ $attachingFileMessage = form.find('.attaching-file-message');
+ $cancelButton = form.find('.button-cancel-uploading-files');
+ $retryLink = form.find('.retry-uploading-link');
+ $uploadProgress = form.find('.uploading-progress');
+ $uploadingErrorContainer = form.find('.uploading-error-container');
+ $uploadingErrorMessage = form.find('.uploading-error-message');
+ $uploadingProgressContainer = form.find('.uploading-progress-container');
+ uploadsPath = window.uploads_path || null;
+ maxFileSize = gon.max_file_size || 10;
+ formTextarea = form.find('.js-gfm-input');
+ formTextarea.wrap('<div class="div-dropzone"></div>');
+ formTextarea.on('paste', (function(_this) {
return function(event) {
return handlePaste(event);
};
})(this));
- $mdArea = $(form_textarea).closest('.md-area');
- $(form).setupMarkdownPreview();
- form_dropzone = $(form).find('.div-dropzone');
- form_dropzone.parent().addClass("div-dropzone-wrapper");
- form_dropzone.append(divHover);
- form_dropzone.find(".div-dropzone-hover").append(iconPaperclip);
- form_dropzone.append(divSpinner);
- form_dropzone.find(".div-dropzone-spinner").append(iconSpinner);
- form_dropzone.find(".div-dropzone-spinner").append(uploadProgress);
- form_dropzone.find(".div-dropzone-spinner").css({
- "opacity": 0,
- "display": "none"
- });
- if (!uploads_path) return;
+ // Add dropzone area to the form.
+ $mdArea = formTextarea.closest('.md-area');
+ form.setupMarkdownPreview();
+ $formDropzone = form.find('.div-dropzone');
+ $formDropzone.parent().addClass('div-dropzone-wrapper');
+ $formDropzone.append(divHover);
+ $formDropzone.find('.div-dropzone-hover').append(iconPaperclip);
+
+ if (!uploadsPath) return;
- dropzone = form_dropzone.dropzone({
- url: uploads_path,
- dictDefaultMessage: "",
+ dropzone = $formDropzone.dropzone({
+ url: uploadsPath,
+ dictDefaultMessage: '',
clickable: true,
- paramName: "file",
- maxFilesize: max_file_size,
+ paramName: 'file',
+ maxFilesize: maxFileSize,
uploadMultiple: false,
headers: {
- "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content")
+ 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
},
previewContainer: false,
processing: function() {
- return $(".div-dropzone-alert").alert("close");
+ return $('.div-dropzone-alert').alert('close');
},
dragover: function() {
$mdArea.addClass('is-dropzone-hover');
- form.find(".div-dropzone-hover").css("opacity", 0.7);
+ form.find('.div-dropzone-hover').css('opacity', 0.7);
},
dragleave: function() {
$mdArea.removeClass('is-dropzone-hover');
- form.find(".div-dropzone-hover").css("opacity", 0);
+ form.find('.div-dropzone-hover').css('opacity', 0);
},
drop: function() {
$mdArea.removeClass('is-dropzone-hover');
- form.find(".div-dropzone-hover").css("opacity", 0);
- form_textarea.focus();
+ form.find('.div-dropzone-hover').css('opacity', 0);
+ formTextarea.focus();
},
success: function(header, response) {
const processingFileCount = this.getQueuedFiles().length + this.getUploadingFiles().length;
const shouldPad = processingFileCount >= 1;
pasteText(response.link.markdown, shouldPad);
+ // Show 'Attach a file' link only when all files have been uploaded.
+ if (!processingFileCount) $attachButton.removeClass('hide');
+ addFileToForm(response.link.url);
},
- error: function(temp) {
- var checkIfMsgExists, errorAlert;
- errorAlert = $(form).find('.error-alert');
- checkIfMsgExists = errorAlert.children().length;
- if (checkIfMsgExists === 0) {
- errorAlert.append(divAlert);
- $(".div-dropzone-alert").append(btnAlert + "Attaching the file failed.");
- }
+ error: function(file, errorMessage = 'Attaching the file failed.', xhr) {
+ // If 'error' event is fired by dropzone, the second parameter is error message.
+ // If the 'errorMessage' parameter is empty, the default error message is set.
+ // If the 'error' event is fired by backend (xhr) error response, the third parameter is
+ // xhr object (xhr.responseText is error message).
+ // On error we hide the 'Attach' and 'Cancel' buttons
+ // and show an error.
+
+ // If there's xhr error message, let's show it instead of dropzone's one.
+ const message = xhr ? xhr.responseText : errorMessage;
+
+ $uploadingErrorContainer.removeClass('hide');
+ $uploadingErrorMessage.html(message);
+ $attachButton.addClass('hide');
+ $cancelButton.addClass('hide');
},
totaluploadprogress: function(totalUploadProgress) {
- uploadProgress.text(Math.round(totalUploadProgress) + "%");
+ updateAttachingMessage(this.files, $attachingFileMessage);
+ $uploadProgress.text(Math.round(totalUploadProgress) + '%');
+ },
+ sending: function(file) {
+ // DOM elements already exist.
+ // Instead of dynamically generating them,
+ // we just either hide or show them.
+ $attachButton.addClass('hide');
+ $uploadingErrorContainer.addClass('hide');
+ $uploadingProgressContainer.removeClass('hide');
+ $cancelButton.removeClass('hide');
},
- sending: function() {
- form_dropzone.find(".div-dropzone-spinner").css({
- "opacity": 0.7,
- "display": "inherit"
- });
+ removedfile: function() {
+ $attachButton.removeClass('hide');
+ $cancelButton.addClass('hide');
+ $uploadingProgressContainer.addClass('hide');
+ $uploadingErrorContainer.addClass('hide');
},
queuecomplete: function() {
- uploadProgress.text("");
- $(".dz-preview").remove();
- $(".markdown-area").trigger("input");
- $(".div-dropzone-spinner").css({
- "opacity": 0,
- "display": "none"
- });
+ $('.dz-preview').remove();
+ $('.markdown-area').trigger('input');
+
+ $uploadingProgressContainer.addClass('hide');
+ $cancelButton.addClass('hide');
}
});
- child = $(dropzone[0]).children("textarea");
+
+ child = $(dropzone[0]).children('textarea');
+
+ // removeAllFiles(true) stops uploading files (if any)
+ // and remove them from dropzone files queue.
+ $cancelButton.on('click', (e) => {
+ const target = e.target.closest('form').querySelector('.div-dropzone');
+
+ e.preventDefault();
+ e.stopPropagation();
+ Dropzone.forElement(target).removeAllFiles(true);
+ });
+
+ // If 'error' event is fired, we store a failed files,
+ // clear dropzone files queue, change status of failed files to undefined,
+ // and add that files to the dropzone files queue again.
+ // addFile() adds file to dropzone files queue and upload it.
+ $retryLink.on('click', (e) => {
+ const dropzoneInstance = Dropzone.forElement(e.target.closest('form').querySelector('.div-dropzone'));
+ const failedFiles = dropzoneInstance.files;
+
+ e.preventDefault();
+
+ // 'true' parameter of removeAllFiles() cancels uploading of files that are being uploaded at the moment.
+ dropzoneInstance.removeAllFiles(true);
+
+ failedFiles.map((failedFile, i) => {
+ const file = failedFile;
+
+ if (file.status === Dropzone.ERROR) {
+ file.status = undefined;
+ file.accepted = undefined;
+ }
+
+ return dropzoneInstance.addFile(file);
+ });
+ });
+
handlePaste = function(event) {
var filename, image, pasteEvent, text;
pasteEvent = event.originalEvent;
@@ -110,25 +161,27 @@ window.DropzoneInput = (function() {
image = isImage(pasteEvent);
if (image) {
event.preventDefault();
- filename = getFilename(pasteEvent) || "image.png";
- text = "{{" + filename + "}}";
+ filename = getFilename(pasteEvent) || 'image.png';
+ text = `{{${filename}}}`;
pasteText(text);
return uploadFile(image.getAsFile(), filename);
}
}
};
+
isImage = function(data) {
var i, item;
i = 0;
while (i < data.clipboardData.items.length) {
item = data.clipboardData.items[i];
- if (item.type.indexOf("image") !== -1) {
+ if (item.type.indexOf('image') !== -1) {
return item;
}
i += 1;
}
return false;
};
+
pasteText = function(text, shouldPad) {
var afterSelection, beforeSelection, caretEnd, caretStart, textEnd;
var formattedText = text;
@@ -142,31 +195,38 @@ window.DropzoneInput = (function() {
$(child).val(beforeSelection + formattedText + afterSelection);
textarea.setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length);
textarea.style.height = `${textarea.scrollHeight}px`;
- return form_textarea.trigger("input");
+ formTextarea.get(0).dispatchEvent(new Event('input'));
+ return formTextarea.trigger('input');
};
+
+ addFileToForm = function(path) {
+ $(form).append('<input type="hidden" name="files[]" value="' + _.escape(path) + '">');
+ };
+
getFilename = function(e) {
var value;
if (window.clipboardData && window.clipboardData.getData) {
- value = window.clipboardData.getData("Text");
+ value = window.clipboardData.getData('Text');
} else if (e.clipboardData && e.clipboardData.getData) {
- value = e.clipboardData.getData("text/plain");
+ value = e.clipboardData.getData('text/plain');
}
value = value.split("\r");
return value.first();
};
+
uploadFile = function(item, filename) {
var formData;
formData = new FormData();
- formData.append("file", item, filename);
+ formData.append('file', item, filename);
return $.ajax({
- url: uploads_path,
- type: "POST",
+ url: uploadsPath,
+ type: 'POST',
data: formData,
- dataType: "json",
+ dataType: 'json',
processData: false,
contentType: false,
headers: {
- "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content")
+ 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
},
beforeSend: function() {
showSpinner();
@@ -183,44 +243,54 @@ window.DropzoneInput = (function() {
}
});
};
+
+ updateAttachingMessage = (files, messageContainer) => {
+ let attachingMessage;
+ const filesCount = files.filter(function(file) {
+ return file.status === 'uploading' ||
+ file.status === 'queued';
+ }).length;
+
+ // Dinamycally change uploading files text depending on files number in
+ // dropzone files queue.
+ if (filesCount > 1) {
+ attachingMessage = 'Attaching ' + filesCount + ' files -';
+ } else {
+ attachingMessage = 'Attaching a file -';
+ }
+
+ messageContainer.text(attachingMessage);
+ };
+
insertToTextArea = function(filename, url) {
return $(child).val(function(index, val) {
- return val.replace("{{" + filename + "}}", url);
+ return val.replace(`{{${filename}}}`, url);
});
};
+
appendToTextArea = function(url) {
return $(child).val(function(index, val) {
return val + url + "\n";
});
};
+
showSpinner = function(e) {
- return form.find(".div-dropzone-spinner").css({
- "opacity": 0.7,
- "display": "inherit"
- });
+ return $uploadingProgressContainer.removeClass('hide');
};
+
closeSpinner = function() {
- return form.find(".div-dropzone-spinner").css({
- "opacity": 0,
- "display": "none"
- });
+ return $uploadingProgressContainer.addClass('hide');
};
+
showError = function(message) {
- var checkIfMsgExists, errorAlert;
- errorAlert = $(form).find('.error-alert');
- checkIfMsgExists = errorAlert.children().length;
- if (checkIfMsgExists === 0) {
- errorAlert.append(divAlert);
- return $(".div-dropzone-alert").append(btnAlert + message);
- }
+ $uploadingErrorContainer.removeClass('hide');
+ $uploadingErrorMessage.html(message);
};
- closeAlertMessage = function() {
- return form.find(".div-dropzone-alert").alert("close");
- };
- form.find(".markdown-selector").click(function(e) {
+
+ form.find('.markdown-selector').click(function(e) {
e.preventDefault();
$(this).closest('.gfm-form').find('.div-dropzone').click();
- form_textarea.focus();
+ formTextarea.focus();
});
}
diff --git a/app/assets/javascripts/environments/components/environment.vue b/app/assets/javascripts/environments/components/environment.vue
index e0088d496eb..8120ef182d4 100644
--- a/app/assets/javascripts/environments/components/environment.vue
+++ b/app/assets/javascripts/environments/components/environment.vue
@@ -1,19 +1,28 @@
<script>
/* global Flash */
+import Visibility from 'visibilityjs';
import EnvironmentsService from '../services/environments_service';
-import EnvironmentTable from './environments_table.vue';
+import environmentTable from './environments_table.vue';
import EnvironmentsStore from '../stores/environments_store';
-import TablePaginationComponent from '../../vue_shared/components/table_pagination';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import tablePagination from '../../vue_shared/components/table_pagination.vue';
import '../../lib/utils/common_utils';
import eventHub from '../event_hub';
+import Poll from '../../lib/utils/poll';
+import environmentsMixin from '../mixins/environments_mixin';
export default {
components: {
- 'environment-table': EnvironmentTable,
- 'table-pagination': TablePaginationComponent,
+ environmentTable,
+ tablePagination,
+ loadingIcon,
},
+ mixins: [
+ environmentsMixin,
+ ],
+
data() {
const environmentsData = document.querySelector('#environments-list-view').dataset;
const store = new EnvironmentsStore();
@@ -33,6 +42,7 @@ export default {
projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath,
newEnvironmentPath: environmentsData.newEnvironmentPath,
helpPagePath: environmentsData.helpPagePath,
+ isMakingRequest: false,
// Pagination Properties,
paginationInformation: {},
@@ -63,17 +73,43 @@ export default {
* Toggles loading property.
*/
created() {
+ const scope = gl.utils.getParameterByName('scope') || this.visibility;
+ const page = gl.utils.getParameterByName('page') || this.pageNumber;
+
this.service = new EnvironmentsService(this.endpoint);
- this.fetchEnvironments();
+ const poll = new Poll({
+ resource: this.service,
+ method: 'get',
+ data: { scope, page },
+ successCallback: this.successCallback,
+ errorCallback: this.errorCallback,
+ notificationCallback: (isMakingRequest) => {
+ this.isMakingRequest = isMakingRequest;
+
+ // We need to verify if any folder is open to also fecth it
+ this.openFolders = this.store.getOpenFolders();
+ },
+ });
+
+ if (!Visibility.hidden()) {
+ this.isLoading = true;
+ poll.makeRequest();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ poll.restart();
+ } else {
+ poll.stop();
+ }
+ });
- eventHub.$on('refreshEnvironments', this.fetchEnvironments);
eventHub.$on('toggleFolder', this.toggleFolder);
eventHub.$on('postAction', this.postAction);
},
- beforeDestroyed() {
- eventHub.$off('refreshEnvironments');
+ beforeDestroy() {
eventHub.$off('toggleFolder');
eventHub.$off('postAction');
},
@@ -102,29 +138,13 @@ export default {
fetchEnvironments() {
const scope = gl.utils.getParameterByName('scope') || this.visibility;
- const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber;
+ const page = gl.utils.getParameterByName('page') || this.pageNumber;
this.isLoading = true;
- return this.service.get(scope, pageNumber)
- .then(resp => ({
- headers: resp.headers,
- body: resp.json(),
- }))
- .then((response) => {
- this.store.storeAvailableCount(response.body.available_count);
- this.store.storeStoppedCount(response.body.stopped_count);
- this.store.storeEnvironments(response.body.environments);
- this.store.setPagination(response.headers);
- })
- .then(() => {
- this.isLoading = false;
- })
- .catch(() => {
- this.isLoading = false;
- // eslint-disable-next-line no-new
- new Flash('An error occurred while fetching the environments.');
- });
+ return this.service.get({ scope, page })
+ .then(this.successCallback)
+ .catch(this.errorCallback);
},
fetchChildEnvironments(folder, folderUrl) {
@@ -144,9 +164,34 @@ export default {
},
postAction(endpoint) {
- this.service.postAction(endpoint)
- .then(() => this.fetchEnvironments())
- .catch(() => new Flash('An error occured while making the request.'));
+ if (!this.isMakingRequest) {
+ this.isLoading = true;
+
+ this.service.postAction(endpoint)
+ .then(() => this.fetchEnvironments())
+ .catch(() => new Flash('An error occured while making the request.'));
+ }
+ },
+
+ successCallback(resp) {
+ this.saveData(resp);
+
+ // If folders are open while polling we need to open them again
+ if (this.openFolders.length) {
+ this.openFolders.map((folder) => {
+ // TODO - Move this to the backend
+ const folderUrl = `${window.location.pathname}/folders/${folder.folderName}`;
+
+ this.store.updateFolder(folder, 'isOpen', true);
+ return this.fetchChildEnvironments(folder, folderUrl);
+ });
+ }
+ },
+
+ errorCallback() {
+ this.isLoading = false;
+ // eslint-disable-next-line no-new
+ new Flash('An error occurred while fetching the environments.');
},
},
};
@@ -185,15 +230,12 @@ export default {
</div>
</div>
- <div class="content-list environments-container">
- <div
- class="environments-list-loading text-center"
- v-if="isLoading">
-
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- </div>
+ <div class="environments-container">
+ <loading-icon
+ label="Loading environments"
+ size="3"
+ v-if="isLoading"
+ />
<div
class="blank-state blank-state-no-icon"
@@ -213,7 +255,7 @@ export default {
v-if="canCreateEnvironmentParsed"
:href="newEnvironmentPath"
class="btn btn-create js-new-environment-button">
- New Environment
+ New environment
</a>
</div>
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index 63bffe8a998..a2448520a5f 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,6 +1,7 @@
<script>
import playIconSvg from 'icons/_icon_play.svg';
import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
props: {
@@ -11,6 +12,10 @@ export default {
},
},
+ components: {
+ loadingIcon,
+ },
+
data() {
return {
playIconSvg,
@@ -61,10 +66,7 @@ export default {
<i
class="fa fa-caret-down"
aria-hidden="true"/>
- <i
- v-if="isLoading"
- class="fa fa-spinner fa-spin"
- aria-hidden="true"/>
+ <loading-icon v-if="isLoading" />
</span>
</button>
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 0ffe9ea17fa..de2269118cd 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -1,5 +1,7 @@
<script>
import Timeago from 'timeago.js';
+import _ from 'underscore';
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import '../../lib/utils/text_utility';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
@@ -19,6 +21,7 @@ const timeagoInstance = new Timeago();
export default {
components: {
+ userAvatarLink,
'commit-component': CommitComponent,
'actions-component': ActionsComponent,
'external-url-component': ExternalUrlComponent,
@@ -59,7 +62,7 @@ export default {
hasLastDeploymentKey() {
if (this.model &&
this.model.last_deployment &&
- !this.$options.isObjectEmpty(this.model.last_deployment)) {
+ !_.isEmpty(this.model.last_deployment)) {
return true;
}
return false;
@@ -310,8 +313,8 @@ export default {
*/
deploymentHasUser() {
return this.model &&
- !this.$options.isObjectEmpty(this.model.last_deployment) &&
- !this.$options.isObjectEmpty(this.model.last_deployment.user);
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.user);
},
/**
@@ -322,8 +325,8 @@ export default {
*/
deploymentUser() {
if (this.model &&
- !this.$options.isObjectEmpty(this.model.last_deployment) &&
- !this.$options.isObjectEmpty(this.model.last_deployment.user)) {
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.user)) {
return this.model.last_deployment.user;
}
return {};
@@ -338,8 +341,8 @@ export default {
*/
shouldRenderBuildName() {
return !this.model.isFolder &&
- !this.$options.isObjectEmpty(this.model.last_deployment) &&
- !this.$options.isObjectEmpty(this.model.last_deployment.deployable);
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.deployable);
},
/**
@@ -380,7 +383,7 @@ export default {
*/
shouldRenderDeploymentID() {
return !this.model.isFolder &&
- !this.$options.isObjectEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment) &&
this.model.last_deployment.iid !== undefined;
},
@@ -410,21 +413,6 @@ export default {
},
},
- /**
- * Helper to verify if certain given object are empty.
- * Should be replaced by lodash _.isEmpty - https://lodash.com/docs/4.17.2#isEmpty
- * @param {Object} object
- * @returns {Bollean}
- */
- isObjectEmpty(object) {
- for (const key in object) { // eslint-disable-line
- if (hasOwnProperty.call(object, key)) {
- return false;
- }
- }
- return true;
- },
-
methods: {
onClickFolder() {
eventHub.$emit('toggleFolder', this.model, this.folderUrl);
@@ -433,14 +421,21 @@ export default {
};
</script>
<template>
- <tr :class="{ 'js-child-row': model.isChildren }">
- <td>
+ <div
+ :class="{ 'js-child-row environment-child-row': model.isChildren, 'folder-row': model.isFolder, 'gl-responsive-table-row': !model.isFolder }"
+ role="row">
+ <div class="table-section section-10" role="gridcell">
+ <div
+ v-if="!model.isFolder"
+ class="table-mobile-header"
+ role="rowheader">
+ Environment
+ </div>
<a
v-if="!model.isFolder"
- class="environment-name"
- :class="{ 'prepend-left-default': model.isChildren }"
+ class="environment-name flex-truncate-parent table-mobile-content"
:href="environmentPath">
- {{model.name}}
+ <span class="flex-truncate-child">{{model.name}}</span>
</a>
<span
v-else
@@ -473,40 +468,44 @@ export default {
{{model.size}}
</span>
</span>
- </td>
+ </div>
- <td class="deployment-column">
+ <div class="table-section section-10 deployment-column hidden-xs hidden-sm" role="gridcell">
<span v-if="shouldRenderDeploymentID">
{{deploymentInternalId}}
</span>
<span v-if="!model.isFolder && deploymentHasUser">
by
- <a
- :href="deploymentUser.web_url"
- class="js-deploy-user-container">
- <img
- class="avatar has-tooltip s20"
- :src="deploymentUser.avatar_url"
- :alt="userImageAltDescription"
- :title="deploymentUser.username" />
- </a>
+ <user-avatar-link
+ class="js-deploy-user-container"
+ :link-href="deploymentUser.web_url"
+ :img-src="deploymentUser.avatar_url"
+ :img-alt="userImageAltDescription"
+ :tooltip-text="deploymentUser.username"
+ />
</span>
- </td>
+ </div>
- <td class="environments-build-cell">
+ <div class="table-section section-15 hidden-xs hidden-sm" role="gridcell">
<a
v-if="shouldRenderBuildName"
class="build-link"
:href="buildPath">
{{buildName}}
</a>
- </td>
+ </div>
- <td>
+ <div class="table-section section-25" role="gridcell">
+ <div
+ v-if="!model.isFolder"
+ role="rowheader"
+ class="table-mobile-header">
+ Commit
+ </div>
<div
v-if="!model.isFolder && hasLastDeploymentKey"
- class="js-commit-component">
+ class="js-commit-component table-mobile-content">
<commit-component
:tag="commitTag"
:commit-ref="commitRef"
@@ -515,25 +514,31 @@ export default {
:title="commitTitle"
:author="commitAuthor"/>
</div>
- <p
+ <div
v-if="!model.isFolder && !hasLastDeploymentKey"
- class="commit-title">
+ class="commit-title table-mobile-content">
No deployments yet
- </p>
- </td>
+ </div>
+ </div>
- <td>
+ <div class="table-section section-10" role="gridcell">
+ <div
+ v-if="!model.isFolder"
+ role="rowheader"
+ class="table-mobile-header">
+ Updated
+ </div>
<span
v-if="!model.isFolder && canShowDate"
- class="environment-created-date-timeago">
+ class="environment-created-date-timeago table-mobile-content">
{{createdDate}}
</span>
- </td>
+ </div>
- <td class="environments-actions">
+ <div class="table-section section-30 environments-actions table-button-footer" role="gridcell">
<div
v-if="!model.isFolder"
- class="btn-group pull-right"
+ class="btn-group environment-action-buttons"
role="group">
<actions-component
@@ -567,6 +572,6 @@ export default {
:retry-url="retryUrl"
/>
</div>
- </td>
- </tr>
+ </div>
+ </div>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue
index 79c019b3491..07cf92281a0 100644
--- a/app/assets/javascripts/environments/components/environment_monitoring.vue
+++ b/app/assets/javascripts/environments/components/environment_monitoring.vue
@@ -19,7 +19,7 @@ export default {
</script>
<template>
<a
- class="btn monitoring-url has-tooltip"
+ class="btn monitoring-url has-tooltip hidden-xs hidden-sm"
data-container="body"
rel="noopener noreferrer nofollow"
:href="monitoringUrl"
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index 44b8730fd09..49dba38edfb 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -6,6 +6,7 @@
* Makes a post request when the button is clicked.
*/
import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
props: {
@@ -20,6 +21,10 @@ export default {
},
},
+ components: {
+ loadingIcon,
+ },
+
data() {
return {
isLoading: false,
@@ -38,7 +43,7 @@ export default {
<template>
<button
type="button"
- class="btn"
+ class="btn hidden-xs hidden-sm"
@click="onClick"
:disabled="isLoading">
@@ -49,9 +54,6 @@ export default {
Rollback
</span>
- <i
- v-if="isLoading"
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
+ <loading-icon v-if="isLoading" />
</button>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue
index f483ea7e937..091c543860b 100644
--- a/app/assets/javascripts/environments/components/environment_stop.vue
+++ b/app/assets/javascripts/environments/components/environment_stop.vue
@@ -4,6 +4,7 @@
* Used in environments table.
*/
import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
props: {
@@ -19,6 +20,10 @@ export default {
};
},
+ components: {
+ loadingIcon,
+ },
+
computed: {
title() {
return 'Stop';
@@ -42,7 +47,7 @@ export default {
<template>
<button
type="button"
- class="btn stop-env-link has-tooltip"
+ class="btn stop-env-link has-tooltip hidden-xs hidden-sm"
data-container="body"
@click="onClick"
:disabled="isLoading"
@@ -51,9 +56,6 @@ export default {
<i
class="fa fa-stop stop-env-icon"
aria-hidden="true" />
- <i
- v-if="isLoading"
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
+ <loading-icon v-if="isLoading" />
</button>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue
index c8c1f17d4d8..1ca65a79951 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -29,7 +29,7 @@ export default {
</script>
<template>
<a
- class="btn terminal-button has-tooltip"
+ class="btn terminal-button has-tooltip hidden-xs hidden-sm"
data-container="body"
:title="title"
:aria-label="title"
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 15eedaf76e1..b1fd9db650b 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -3,10 +3,12 @@
* Render environments table.
*/
import EnvironmentTableRowComponent from './environment_item.vue';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
components: {
'environment-item': EnvironmentTableRowComponent,
+ loadingIcon,
},
props: {
@@ -43,70 +45,59 @@ export default {
};
</script>
<template>
- <table class="table ci-table">
- <thead>
- <tr>
- <th class="environments-name">
- Environment
- </th>
- <th class="environments-deploy">
- Last deployment
- </th>
- <th class="environments-build">
- Job
- </th>
- <th class="environments-commit">
- Commit
- </th>
- <th class="environments-date">
- Updated
- </th>
- <th class="environments-actions"></th>
- </tr>
- </thead>
- <tbody>
- <template
- v-for="model in environments"
- v-bind:model="model">
- <tr
- is="environment-item"
- :model="model"
- :can-create-deployment="canCreateDeployment"
- :can-read-environment="canReadEnvironment"
- />
+ <div class="ci-table" role="grid">
+ <div class="gl-responsive-table-row table-row-header" role="row">
+ <div class="table-section section-10 environments-name" role="columnheader">
+ Environment
+ </div>
+ <div class="table-section section-10 environments-deploy" role="columnheader">
+ Deployment
+ </div>
+ <div class="table-section section-15 environments-build" role="columnheader">
+ Job
+ </div>
+ <div class="table-section section-25 environments-commit" role="columnheader">
+ Commit
+ </div>
+ <div class="table-section section-10 environments-date" role="columnheader">
+ Updated
+ </div>
+ </div>
+ <template
+ v-for="model in environments"
+ v-bind:model="model">
+ <div
+ is="environment-item"
+ :model="model"
+ :can-create-deployment="canCreateDeployment"
+ :can-read-environment="canReadEnvironment"
+ />
- <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0">
- <tr v-if="isLoadingFolderContent">
- <td colspan="6" class="text-center">
- <i
- class="fa fa-spin fa-spinner fa-2x"
- aria-hidden="true" />
- </td>
- </tr>
+ <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0">
+ <div v-if="isLoadingFolderContent">
+ <loading-icon size="2" />
+ </div>
- <template v-else>
- <tr
- is="environment-item"
- v-for="children in model.children"
- :model="children"
- :can-create-deployment="canCreateDeployment"
- :can-read-environment="canReadEnvironment"
- />
+ <template v-else>
+ <div
+ is="environment-item"
+ v-for="children in model.children"
+ :model="children"
+ :can-create-deployment="canCreateDeployment"
+ :can-read-environment="canReadEnvironment"
+ />
- <tr>
- <td
- colspan="6"
- class="text-center">
- <a
- :href="folderUrl(model)"
- class="btn btn-default">
- Show all
- </a>
- </td>
- </tr>
- </template>
+ <div>
+ <div class="text-center prepend-top-10">
+ <a
+ :href="folderUrl(model)"
+ class="btn btn-default">
+ Show all
+ </a>
+ </div>
+ </div>
</template>
</template>
- </tbody>
- </table>
+ </template>
+ </div>
</template>
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue
index f4a0c390c91..925503a01c4 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.vue
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue
@@ -1,18 +1,27 @@
<script>
/* global Flash */
+import Visibility from 'visibilityjs';
import EnvironmentsService from '../services/environments_service';
-import EnvironmentTable from '../components/environments_table.vue';
+import environmentTable from '../components/environments_table.vue';
import EnvironmentsStore from '../stores/environments_store';
-import TablePaginationComponent from '../../vue_shared/components/table_pagination';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import tablePagination from '../../vue_shared/components/table_pagination.vue';
+import Poll from '../../lib/utils/poll';
+import eventHub from '../event_hub';
+import environmentsMixin from '../mixins/environments_mixin';
import '../../lib/utils/common_utils';
-import '../../vue_shared/vue_resource_interceptor';
export default {
components: {
- 'environment-table': EnvironmentTable,
- 'table-pagination': TablePaginationComponent,
+ environmentTable,
+ tablePagination,
+ loadingIcon,
},
+ mixins: [
+ environmentsMixin,
+ ],
+
data() {
const environmentsData = document.querySelector('#environments-folder-list-view').dataset;
const store = new EnvironmentsStore();
@@ -74,33 +83,39 @@ export default {
*/
created() {
const scope = gl.utils.getParameterByName('scope') || this.visibility;
- const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber;
-
- const endpoint = `${this.endpoint}?scope=${scope}&page=${pageNumber}`;
-
- this.service = new EnvironmentsService(endpoint);
-
- this.isLoading = true;
-
- return this.service.get()
- .then(resp => ({
- headers: resp.headers,
- body: resp.json(),
- }))
- .then((response) => {
- this.store.storeAvailableCount(response.body.available_count);
- this.store.storeStoppedCount(response.body.stopped_count);
- this.store.storeEnvironments(response.body.environments);
- this.store.setPagination(response.headers);
- })
- .then(() => {
- this.isLoading = false;
- })
- .catch(() => {
- this.isLoading = false;
- // eslint-disable-next-line no-new
- new Flash('An error occurred while fetching the environments.', 'alert');
- });
+ const page = gl.utils.getParameterByName('page') || this.pageNumber;
+
+ this.service = new EnvironmentsService(this.endpoint);
+
+ const poll = new Poll({
+ resource: this.service,
+ method: 'get',
+ data: { scope, page },
+ successCallback: this.successCallback,
+ errorCallback: this.errorCallback,
+ notificationCallback: (isMakingRequest) => {
+ this.isMakingRequest = isMakingRequest;
+ },
+ });
+
+ if (!Visibility.hidden()) {
+ this.isLoading = true;
+ poll.makeRequest();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ poll.restart();
+ } else {
+ poll.stop();
+ }
+ });
+
+ eventHub.$on('postAction', this.postAction);
+ },
+
+ beforeDestroyed() {
+ eventHub.$off('postAction');
},
methods: {
@@ -115,6 +130,37 @@ export default {
gl.utils.visitUrl(param);
return param;
},
+
+ fetchEnvironments() {
+ const scope = gl.utils.getParameterByName('scope') || this.visibility;
+ const page = gl.utils.getParameterByName('page') || this.pageNumber;
+
+ this.isLoading = true;
+
+ return this.service.get({ scope, page })
+ .then(this.successCallback)
+ .catch(this.errorCallback);
+ },
+
+ successCallback(resp) {
+ this.saveData(resp);
+ },
+
+ errorCallback() {
+ this.isLoading = false;
+ // eslint-disable-next-line no-new
+ new Flash('An error occurred while fetching the environments.');
+ },
+
+ postAction(endpoint) {
+ if (!this.isMakingRequest) {
+ this.isLoading = true;
+
+ this.service.postAction(endpoint)
+ .then(() => this.fetchEnvironments())
+ .catch(() => new Flash('An error occured while making the request.'));
+ }
+ },
},
};
</script>
@@ -153,13 +199,12 @@ export default {
</div>
<div class="environments-container">
- <div
- class="environments-list-loading text-center"
- v-if="isLoading">
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true"/>
- </div>
+
+ <loading-icon
+ label="Loading environments"
+ v-if="isLoading"
+ size="3"
+ />
<div
class="table-holder"
diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
new file mode 100644
index 00000000000..25b24fbd6dc
--- /dev/null
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -0,0 +1,17 @@
+export default {
+ methods: {
+ saveData(resp) {
+ const response = {
+ headers: resp.headers,
+ body: resp.json(),
+ };
+
+ this.isLoading = false;
+
+ this.store.storeAvailableCount(response.body.available_count);
+ this.store.storeStoppedCount(response.body.stopped_count);
+ this.store.storeEnvironments(response.body.environments);
+ this.store.setPagination(response.headers);
+ },
+ },
+};
diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js
index 8adb53ea86d..03ab74b3338 100644
--- a/app/assets/javascripts/environments/services/environments_service.js
+++ b/app/assets/javascripts/environments/services/environments_service.js
@@ -10,7 +10,8 @@ export default class EnvironmentsService {
this.folderResults = 3;
}
- get(scope, page) {
+ get(options = {}) {
+ const { scope, page } = options;
return this.environments.get({ scope, page });
}
diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js
index 158e7922e3c..8a2f6a473de 100644
--- a/app/assets/javascripts/environments/stores/environments_store.js
+++ b/app/assets/javascripts/environments/stores/environments_store.js
@@ -153,4 +153,10 @@ export default class EnvironmentsStore {
return updatedEnvironments;
}
+ getOpenFolders() {
+ const environments = this.state.environments;
+
+ return environments.filter(env => env.isFolder && env.isOpen);
+ }
+
}
diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js
index 59d6508fc02..534e651b030 100644
--- a/app/assets/javascripts/files_comment_button.js
+++ b/app/assets/javascripts/files_comment_button.js
@@ -3,7 +3,6 @@
/* global notes */
let $commentButtonTemplate;
-var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
window.FilesCommentButton = (function() {
var COMMENT_BUTTON_CLASS, EMPTY_CELL_CLASS, LINE_COLUMN_CLASSES, LINE_CONTENT_CLASS, LINE_HOLDER_CLASS, LINE_NUMBER_CLASS, OLD_LINE_CLASS, TEXT_FILE_SELECTOR, UNFOLDABLE_LINE_CLASS;
@@ -27,8 +26,8 @@ window.FilesCommentButton = (function() {
TEXT_FILE_SELECTOR = '.text-file';
function FilesCommentButton(filesContainerElement) {
- this.render = bind(this.render, this);
- this.hideButton = bind(this.hideButton, this);
+ this.render = this.render.bind(this);
+ this.hideButton = this.hideButton.bind(this);
this.isParallelView = notes.isParallelView();
filesContainerElement.on('mouseover', LINE_COLUMN_CLASSES, this.render)
.on('mouseleave', LINE_COLUMN_CLASSES, this.hideButton);
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 15052dbd362..c51d4b056af 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
@@ -13,13 +13,17 @@ export default {
required: false,
default: true,
},
+ allowedKeys: {
+ type: Array,
+ required: true,
+ },
},
computed: {
processedItems() {
return this.items.map((item) => {
const { tokens, searchToken }
- = gl.FilteredSearchTokenizer.processTokens(item);
+ = gl.FilteredSearchTokenizer.processTokens(item, this.allowedKeys);
const resultantTokens = tokens.map(token => ({
prefix: `${token.key}:`,
diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js
index 5e9434fd48f..2af242a69df 100644
--- a/app/assets/javascripts/filtered_search/dropdown_hint.js
+++ b/app/assets/javascripts/filtered_search/dropdown_hint.js
@@ -1,16 +1,19 @@
import Filter from '~/droplab/plugins/filter';
-
-require('./filtered_search_dropdown');
+import './filtered_search_dropdown';
class DropdownHint extends gl.FilteredSearchDropdown {
- constructor(droplab, dropdown, input, filter) {
+ constructor(droplab, dropdown, input, tokenKeys, filter) {
super(droplab, dropdown, input, filter);
this.config = {
Filter: {
template: 'hint',
- filterFunction: gl.DropdownUtils.filterHint.bind(null, input),
+ filterFunction: gl.DropdownUtils.filterHint.bind(null, {
+ input,
+ allowedKeys: tokenKeys.getKeys(),
+ }),
},
};
+ this.tokenKeys = tokenKeys;
}
itemClicked(e) {
@@ -53,20 +56,13 @@ class DropdownHint extends gl.FilteredSearchDropdown {
}
renderContent() {
- const dropdownData = [];
-
- [].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
- const { icon, hint, tag, type } = dropdownMenu.dataset;
- if (icon && hint && tag) {
- dropdownData.push(
- Object.assign({
- icon: `fa-${icon}`,
- hint,
- tag: `<${tag}>`,
- }, type && { type }),
- );
- }
- });
+ const dropdownData = gl.FilteredSearchTokenKeys.get()
+ .map(tokenKey => ({
+ icon: `fa-${tokenKey.icon}`,
+ hint: tokenKey.key,
+ tag: `<${tokenKey.symbol}${tokenKey.key}>`,
+ type: tokenKey.type,
+ }));
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData);
diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js
index 982dc4b61be..34a9e34070c 100644
--- a/app/assets/javascripts/filtered_search/dropdown_non_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js
@@ -2,11 +2,10 @@
import Ajax from '~/droplab/plugins/ajax';
import Filter from '~/droplab/plugins/filter';
-
-require('./filtered_search_dropdown');
+import './filtered_search_dropdown';
class DropdownNonUser extends gl.FilteredSearchDropdown {
- constructor(droplab, dropdown, input, filter, endpoint, symbol) {
+ constructor(droplab, dropdown, input, tokenKeys, filter, endpoint, symbol) {
super(droplab, dropdown, input, filter);
this.symbol = symbol;
this.config = {
diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js
index 74cec3d75fe..65c1b2050ac 100644
--- a/app/assets/javascripts/filtered_search/dropdown_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_user.js
@@ -1,11 +1,10 @@
/* global Flash */
import AjaxFilter from '~/droplab/plugins/ajax_filter';
-
-require('./filtered_search_dropdown');
+import './filtered_search_dropdown';
class DropdownUser extends gl.FilteredSearchDropdown {
- constructor(droplab, dropdown, input, filter) {
+ constructor(droplab, dropdown, input, tokenKeys, filter) {
super(droplab, dropdown, input, filter);
this.config = {
AjaxFilter: {
@@ -19,6 +18,9 @@ class DropdownUser extends gl.FilteredSearchDropdown {
},
searchValueFunction: this.getSearchInput.bind(this),
loadingTemplate: this.loadingTemplate,
+ onLoadingFinished: () => {
+ this.hideCurrentUser();
+ },
onError() {
/* eslint-disable no-new */
new Flash('An error occured fetching the dropdown data.');
@@ -26,6 +28,12 @@ class DropdownUser extends gl.FilteredSearchDropdown {
},
},
};
+ this.tokenKeys = tokenKeys;
+ }
+
+ hideCurrentUser() {
+ const currenUserItem = this.dropdown.querySelector('.js-current-user');
+ currenUserItem.classList.add('hidden');
}
itemClicked(e) {
@@ -44,7 +52,7 @@ class DropdownUser extends gl.FilteredSearchDropdown {
getSearchInput() {
const query = gl.DropdownUtils.getSearchInput(this.input);
- const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query);
+ const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
let value = lastToken || '';
diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js
index bc7c1dffece..ef8fe071012 100644
--- a/app/assets/javascripts/filtered_search/dropdown_utils.js
+++ b/app/assets/javascripts/filtered_search/dropdown_utils.js
@@ -50,10 +50,12 @@ class DropdownUtils {
return updatedItem;
}
- static filterHint(input, item) {
+ static filterHint(config, item) {
+ const { input, allowedKeys } = config;
const updatedItem = item;
const searchInput = gl.DropdownUtils.getSearchQuery(input);
- const { lastToken, tokens } = gl.FilteredSearchTokenizer.processTokens(searchInput);
+ const { lastToken, tokens } =
+ gl.FilteredSearchTokenizer.processTokens(searchInput, allowedKeys);
const lastKey = lastToken.key || lastToken || '';
const allowMultiple = item.type === 'array';
const itemInExistingTokens = tokens.some(t => t.key === item.hint);
@@ -100,10 +102,13 @@ class DropdownUtils {
if (token.classList.contains('js-visual-token')) {
const name = token.querySelector('.name');
const value = token.querySelector('.value');
+ const valueContainer = token.querySelector('.value-container');
const symbol = value && value.dataset.symbol ? value.dataset.symbol : '';
let valueText = '';
- if (value && value.innerText) {
+ if (valueContainer && valueContainer.dataset.originalValue) {
+ valueText = valueContainer.dataset.originalValue;
+ } else if (value && value.innerText) {
valueText = value.innerText;
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js
index 856eb6590ee..132b6fe698a 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_bundle.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_bundle.js
@@ -1,10 +1,10 @@
-require('./dropdown_hint');
-require('./dropdown_non_user');
-require('./dropdown_user');
-require('./dropdown_utils');
-require('./filtered_search_dropdown_manager');
-require('./filtered_search_dropdown');
-require('./filtered_search_manager');
-require('./filtered_search_token_keys');
-require('./filtered_search_tokenizer');
-require('./filtered_search_visual_tokens');
+import './dropdown_hint';
+import './dropdown_non_user';
+import './dropdown_user';
+import './dropdown_utils';
+import './filtered_search_token_keys';
+import './filtered_search_dropdown_manager';
+import './filtered_search_dropdown';
+import './filtered_search_manager';
+import './filtered_search_tokenizer';
+import './filtered_search_visual_tokens';
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
index 49a6cd1ac77..6bc6bc43f51 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -2,10 +2,10 @@ import DropLab from '~/droplab/drop_lab';
import FilteredSearchContainer from './container';
class FilteredSearchDropdownManager {
- constructor(baseEndpoint = '', page) {
+ constructor(baseEndpoint = '', tokenizer, page) {
this.container = FilteredSearchContainer.container;
this.baseEndpoint = baseEndpoint.replace(/\/$/, '');
- this.tokenizer = gl.FilteredSearchTokenizer;
+ this.tokenizer = tokenizer;
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.page = page;
@@ -98,7 +98,8 @@ class FilteredSearchDropdownManager {
if (!mappingKey.reference) {
const dl = this.droplab;
- const defaultArguments = [null, dl, element, this.filteredSearchInput, key];
+ const defaultArguments =
+ [null, dl, element, this.filteredSearchInput, this.filteredSearchTokenKeys, key];
const glArguments = defaultArguments.concat(mappingKey.extraArguments || []);
// Passing glArguments to `new gl[glClass](<arguments>)`
@@ -141,7 +142,8 @@ class FilteredSearchDropdownManager {
setDropdown() {
const query = gl.DropdownUtils.getSearchQuery(true);
- const { lastToken, searchToken } = this.tokenizer.processTokens(query);
+ const { lastToken, searchToken } =
+ this.tokenizer.processTokens(query, this.filteredSearchTokenKeys.getKeys());
if (this.currentDropdown) {
this.updateCurrentDropdownOffset();
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 9fea563370f..8f547bd8f1f 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -6,6 +6,7 @@ import eventHub from './event_hub';
class FilteredSearchManager {
constructor(page) {
+ this.page = page;
this.container = FilteredSearchContainer.container;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.filteredSearchInputForm = this.filteredSearchInput.form;
@@ -15,13 +16,20 @@ class FilteredSearchManager {
this.recentSearchesStore = new RecentSearchesStore({
isLocalStorageAvailable: RecentSearchesService.isAvailable(),
+ allowedKeys: this.filteredSearchTokenKeys.getKeys(),
});
- let recentSearchesKey = 'issue-recent-searches';
- if (page === 'merge_requests') {
- recentSearchesKey = 'merge-request-recent-searches';
+ this.searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown');
+ const projectPath = this.searchHistoryDropdownElement ?
+ this.searchHistoryDropdownElement.dataset.projectFullPath : 'project';
+ let recentSearchesPagePrefix = 'issue-recent-searches';
+ if (this.page === 'merge_requests') {
+ recentSearchesPagePrefix = 'merge-request-recent-searches';
}
+ const recentSearchesKey = `${projectPath}-${recentSearchesPagePrefix}`;
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
+ }
+ setup() {
// Fetch recent searches from localStorage
this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch()
.catch((error) => {
@@ -42,12 +50,12 @@ class FilteredSearchManager {
if (this.filteredSearchInput) {
this.tokenizer = gl.FilteredSearchTokenizer;
- this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page);
+ this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, this.page);
this.recentSearchesRoot = new RecentSearchesRoot(
this.recentSearchesStore,
this.recentSearchesService,
- document.querySelector('.js-filtered-search-history-dropdown'),
+ this.searchHistoryDropdownElement,
);
this.recentSearchesRoot.init();
@@ -69,6 +77,41 @@ class FilteredSearchManager {
}
}
+ bindStateEvents() {
+ this.stateFilters = document.querySelector('.container-fluid .issues-state-filters');
+
+ if (this.stateFilters) {
+ this.searchStateWrapper = this.searchState.bind(this);
+
+ this.stateFilters.querySelector('[data-state="opened"]')
+ .addEventListener('click', this.searchStateWrapper);
+ this.stateFilters.querySelector('[data-state="closed"]')
+ .addEventListener('click', this.searchStateWrapper);
+ this.stateFilters.querySelector('[data-state="all"]')
+ .addEventListener('click', this.searchStateWrapper);
+
+ this.mergedState = this.stateFilters.querySelector('[data-state="merged"]');
+ if (this.mergedState) {
+ this.mergedState.addEventListener('click', this.searchStateWrapper);
+ }
+ }
+ }
+
+ unbindStateEvents() {
+ if (this.stateFilters) {
+ this.stateFilters.querySelector('[data-state="opened"]')
+ .removeEventListener('click', this.searchStateWrapper);
+ this.stateFilters.querySelector('[data-state="closed"]')
+ .removeEventListener('click', this.searchStateWrapper);
+ this.stateFilters.querySelector('[data-state="all"]')
+ .removeEventListener('click', this.searchStateWrapper);
+
+ if (this.mergedState) {
+ this.mergedState.removeEventListener('click', this.searchStateWrapper);
+ }
+ }
+ }
+
bindEvents() {
this.handleFormSubmit = this.handleFormSubmit.bind(this);
this.setDropdownWrapper = this.dropdownManager.setDropdown.bind(this.dropdownManager);
@@ -97,15 +140,15 @@ class FilteredSearchManager {
this.filteredSearchInput.addEventListener('click', this.tokenChange);
this.filteredSearchInput.addEventListener('keyup', this.tokenChange);
this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper);
- this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.addEventListener('click', this.removeTokenWrapper);
- this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper);
+ this.tokensContainer.addEventListener('click', this.editTokenWrapper);
this.clearSearchButton.addEventListener('click', this.onClearSearchWrapper);
- document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.addEventListener('click', this.unselectEditTokensWrapper);
document.addEventListener('click', this.removeInputContainerFocusWrapper);
document.addEventListener('keydown', this.removeSelectedTokenKeydownWrapper);
eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
+
+ this.bindStateEvents();
}
unbindEvents() {
@@ -119,15 +162,15 @@ class FilteredSearchManager {
this.filteredSearchInput.removeEventListener('click', this.tokenChange);
this.filteredSearchInput.removeEventListener('keyup', this.tokenChange);
this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper);
- this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.removeEventListener('click', this.removeTokenWrapper);
- this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper);
+ this.tokensContainer.removeEventListener('click', this.editTokenWrapper);
this.clearSearchButton.removeEventListener('click', this.onClearSearchWrapper);
- document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.removeEventListener('click', this.unselectEditTokensWrapper);
document.removeEventListener('click', this.removeInputContainerFocusWrapper);
document.removeEventListener('keydown', this.removeSelectedTokenKeydownWrapper);
eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
+
+ this.unbindStateEvents();
}
checkForBackspace(e) {
@@ -136,7 +179,9 @@ class FilteredSearchManager {
if (e.keyCode === 8 || e.keyCode === 46) {
const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
- if (this.filteredSearchInput.value === '' && lastVisualToken) {
+ const sanitizedTokenName = lastVisualToken && lastVisualToken.querySelector('.name').textContent.trim();
+ const canEdit = sanitizedTokenName && this.canEdit && this.canEdit(sanitizedTokenName);
+ if (this.filteredSearchInput.value === '' && lastVisualToken && canEdit) {
this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial();
gl.FilteredSearchVisualTokens.removeLastTokenPartial();
}
@@ -197,23 +242,13 @@ class FilteredSearchManager {
}
}
- static selectToken(e) {
- const button = e.target.closest('.selectable');
- const removeButtonSelected = e.target.closest('.remove-token');
-
- if (!removeButtonSelected && button) {
- e.preventDefault();
- e.stopPropagation();
- gl.FilteredSearchVisualTokens.selectToken(button);
- }
- }
-
removeToken(e) {
const removeButtonSelected = e.target.closest('.remove-token');
if (removeButtonSelected) {
e.preventDefault();
- e.stopPropagation();
+ // Prevent editToken from being triggered after token is removed
+ e.stopImmediatePropagation();
const button = e.target.closest('.selectable');
gl.FilteredSearchVisualTokens.selectToken(button, true);
@@ -235,8 +270,12 @@ class FilteredSearchManager {
editToken(e) {
const token = e.target.closest('.js-visual-token');
+ const sanitizedTokenName = token && token.querySelector('.name').textContent.trim();
+ const canEdit = this.canEdit && this.canEdit(sanitizedTokenName);
- if (token) {
+ if (token && canEdit) {
+ e.preventDefault();
+ e.stopPropagation();
gl.FilteredSearchVisualTokens.editToken(token);
this.tokenChange();
}
@@ -314,7 +353,7 @@ class FilteredSearchManager {
handleInputVisualToken() {
const input = this.filteredSearchInput;
const { tokens, searchToken }
- = gl.FilteredSearchTokenizer.processTokens(input.value);
+ = this.tokenizer.processTokens(input.value, this.filteredSearchTokenKeys.getKeys());
const { isLastVisualTokenValid }
= gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
@@ -386,7 +425,12 @@ class FilteredSearchManager {
if (condition) {
hasFilteredSearch = true;
- gl.FilteredSearchVisualTokens.addFilterVisualToken(condition.tokenKey, condition.value);
+ const canEdit = this.canEdit && this.canEdit(condition.tokenKey);
+ gl.FilteredSearchVisualTokens.addFilterVisualToken(
+ condition.tokenKey,
+ condition.value,
+ canEdit,
+ );
} else {
// Sanitize value since URL converts spaces into +
// Replace before decode so that we know what was originally + versus the encoded +
@@ -405,18 +449,27 @@ class FilteredSearchManager {
}
hasFilteredSearch = true;
- gl.FilteredSearchVisualTokens.addFilterVisualToken(sanitizedKey, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`);
+ const canEdit = this.canEdit && this.canEdit(sanitizedKey);
+ gl.FilteredSearchVisualTokens.addFilterVisualToken(
+ sanitizedKey,
+ `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
+ canEdit,
+ );
} else if (!match && keyParam === 'assignee_id') {
const id = parseInt(value, 10);
if (usernameParams[id]) {
hasFilteredSearch = true;
- gl.FilteredSearchVisualTokens.addFilterVisualToken('assignee', `@${usernameParams[id]}`);
+ const tokenName = 'assignee';
+ const canEdit = this.canEdit && this.canEdit(tokenName);
+ gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
}
} else if (!match && keyParam === 'author_id') {
const id = parseInt(value, 10);
if (usernameParams[id]) {
hasFilteredSearch = true;
- gl.FilteredSearchVisualTokens.addFilterVisualToken('author', `@${usernameParams[id]}`);
+ const tokenName = 'author';
+ const canEdit = this.canEdit && this.canEdit(tokenName);
+ gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
}
} else if (!match && keyParam === 'search') {
hasFilteredSearch = true;
@@ -433,15 +486,27 @@ class FilteredSearchManager {
}
}
- search() {
+ searchState(e) {
+ const target = e.currentTarget;
+ // remove focus outline after click
+ target.blur();
+
+ const state = target.dataset && target.dataset.state;
+
+ if (state) {
+ this.search(state);
+ }
+ }
+
+ search(state = null) {
const paths = [];
const searchQuery = gl.DropdownUtils.getSearchQuery();
this.saveCurrentSearchQuery();
const { tokens, searchToken }
- = this.tokenizer.processTokens(searchQuery);
- const currentState = gl.utils.getParameterByName('state') || 'opened';
+ = this.tokenizer.processTokens(searchQuery, this.filteredSearchTokenKeys.getKeys());
+ const currentState = state || gl.utils.getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`);
tokens.forEach((token) => {
@@ -511,6 +576,11 @@ class FilteredSearchManager {
this.filteredSearchInput.dispatchEvent(new CustomEvent('input'));
this.search();
}
+
+ // eslint-disable-next-line class-methods-use-this
+ canEdit() {
+ return true;
+ }
}
window.gl = window.gl || {};
diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
index 1abad9d1b73..025d4d8795b 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
@@ -3,21 +3,25 @@ const tokenKeys = [{
type: 'string',
param: 'username',
symbol: '@',
+ icon: 'pencil',
}, {
key: 'assignee',
type: 'string',
param: 'username',
symbol: '@',
+ icon: 'user',
}, {
key: 'milestone',
type: 'string',
param: 'title',
symbol: '%',
+ icon: 'clock-o',
}, {
key: 'label',
type: 'array',
param: 'name[]',
symbol: '~',
+ icon: 'tag',
}];
const alternativeTokenKeys = [{
@@ -56,6 +60,10 @@ class FilteredSearchTokenKeys {
return tokenKeys;
}
+ static getKeys() {
+ return tokenKeys.map(i => i.key);
+ }
+
static getAlternatives() {
return alternativeTokenKeys;
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
index 2808e4b238a..f2e66503e5e 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
@@ -1,8 +1,7 @@
-require('./filtered_search_token_keys');
+import './filtered_search_token_keys';
class FilteredSearchTokenizer {
- static processTokens(input) {
- const allowedKeys = gl.FilteredSearchTokenKeys.get().map(i => i.key);
+ static processTokens(input, allowedKeys) {
// Regex extracts `(token):(symbol)(value)`
// Values that start with a double quote must end in a double quote (same for single)
const tokenRegex = new RegExp(`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g');
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 f3003b86493..e9278140af0 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -1,6 +1,7 @@
-import AjaxCache from '~/lib/utils/ajax_cache';
-import '~/flash'; /* global Flash */
+import AjaxCache from '../lib/utils/ajax_cache';
+import '../flash'; /* global Flash */
import FilteredSearchContainer from './container';
+import UsersCache from '../lib/utils/users_cache';
class FilteredSearchVisualTokens {
static getLastVisualTokenBeforeInput() {
@@ -36,15 +37,22 @@ class FilteredSearchVisualTokens {
}
}
- static createVisualTokenElementHTML() {
+ static createVisualTokenElementHTML(canEdit = true) {
+ let removeTokenMarkup = '';
+ if (canEdit) {
+ removeTokenMarkup = `
+ <div class="remove-token" role="button">
+ <i class="fa fa-close"></i>
+ </div>
+ `;
+ }
+
return `
<div class="selectable" role="button">
<div class="name"></div>
<div class="value-container">
<div class="value"></div>
- <div class="remove-token" role="button">
- <i class="fa fa-close"></i>
- </div>
+ ${removeTokenMarkup}
</div>
</div>
`;
@@ -75,22 +83,52 @@ class FilteredSearchVisualTokens {
.catch(() => new Flash('An error occurred while fetching label colors.'));
}
+ static updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) {
+ if (tokenValue === 'none') {
+ return Promise.resolve();
+ }
+
+ const username = tokenValue.replace(/^@/, '');
+ return UsersCache.retrieve(username)
+ .then((user) => {
+ if (!user) {
+ return;
+ }
+
+ /* eslint-disable no-param-reassign */
+ tokenValueContainer.dataset.originalValue = tokenValue;
+ tokenValueElement.innerHTML = `
+ <img class="avatar s20" src="${user.avatar_url}" alt="${user.name}'s avatar">
+ ${user.name}
+ `;
+ /* eslint-enable no-param-reassign */
+ })
+ // ignore error and leave username in the search bar
+ .catch(() => { });
+ }
+
static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
const tokenValueContainer = parentElement.querySelector('.value-container');
- tokenValueContainer.querySelector('.value').innerText = tokenValue;
+ const tokenValueElement = tokenValueContainer.querySelector('.value');
+ tokenValueElement.innerText = tokenValue;
- if (tokenName.toLowerCase() === 'label') {
+ const tokenType = tokenName.toLowerCase();
+ if (tokenType === 'label') {
FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue);
+ } else if ((tokenType === 'author') || (tokenType === 'assignee')) {
+ FilteredSearchVisualTokens.updateUserTokenAppearance(
+ tokenValueContainer, tokenValueElement, tokenValue,
+ );
}
}
- static addVisualTokenElement(name, value, isSearchTerm) {
+ static addVisualTokenElement(name, value, isSearchTerm, canEdit) {
const li = document.createElement('li');
li.classList.add('js-visual-token');
li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token');
if (value) {
- li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML();
+ li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(canEdit);
FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value);
} else {
li.innerHTML = '<div class="name"></div>';
@@ -114,20 +152,20 @@ class FilteredSearchVisualTokens {
}
}
- static addFilterVisualToken(tokenName, tokenValue) {
+ static addFilterVisualToken(tokenName, tokenValue, canEdit) {
const { lastVisualToken, isLastVisualTokenValid }
= FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const addVisualTokenElement = FilteredSearchVisualTokens.addVisualTokenElement;
if (isLastVisualTokenValid) {
- addVisualTokenElement(tokenName, tokenValue, false);
+ addVisualTokenElement(tokenName, tokenValue, false, canEdit);
} else {
const previousTokenName = lastVisualToken.querySelector('.name').innerText;
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
tokensContainer.removeChild(lastVisualToken);
const value = tokenValue || tokenName;
- addVisualTokenElement(previousTokenName, value, false);
+ addVisualTokenElement(previousTokenName, value, false, canEdit);
}
}
@@ -146,6 +184,12 @@ class FilteredSearchVisualTokens {
if (!lastVisualToken) return '';
+ const valueContainer = lastVisualToken.querySelector('.value-container');
+ const originalValue = valueContainer && valueContainer.dataset.originalValue;
+ if (originalValue) {
+ return originalValue;
+ }
+
const value = lastVisualToken.querySelector('.value');
const name = lastVisualToken.querySelector('.name');
@@ -198,17 +242,28 @@ class FilteredSearchVisualTokens {
const inputLi = input.parentElement;
tokenContainer.replaceChild(inputLi, token);
- const name = token.querySelector('.name');
- const value = token.querySelector('.value');
+ const nameElement = token.querySelector('.name');
+ let value;
- if (token.classList.contains('filtered-search-token') && value) {
- FilteredSearchVisualTokens.addFilterVisualToken(name.innerText);
- input.value = value.innerText;
- } else {
- // token is a search term
- input.value = name.innerText;
+ if (token.classList.contains('filtered-search-token')) {
+ FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText);
+
+ const valueContainerElement = token.querySelector('.value-container');
+ value = valueContainerElement.dataset.originalValue;
+
+ if (!value) {
+ const valueElement = valueContainerElement.querySelector('.value');
+ value = valueElement.innerText;
+ }
+ }
+
+ // token is a search term
+ if (!value) {
+ value = nameElement.innerText;
}
+ input.value = value;
+
// Opens dropdown
const inputEvent = new Event('input');
input.dispatchEvent(inputEvent);
diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js
index b2e6f63aacf..27e49d4fb96 100644
--- a/app/assets/javascripts/filtered_search/recent_searches_root.js
+++ b/app/assets/javascripts/filtered_search/recent_searches_root.js
@@ -37,6 +37,7 @@ class RecentSearchesRoot {
<recent-searches-dropdown-content
:items="recentSearches"
:is-local-storage-available="isLocalStorageAvailable"
+ :allowed-keys="allowedKeys"
/>
`,
components: {
diff --git a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
index 066be69766a..aaa0c349d93 100644
--- a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
+++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
@@ -1,9 +1,11 @@
import _ from 'underscore';
class RecentSearchesStore {
- constructor(initialState = {}) {
+ constructor(initialState = {}, allowedKeys) {
this.state = Object.assign({
+ isLocalStorageAvailable: true,
recentSearches: [],
+ allowedKeys,
}, initialState);
}
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index eec30624ff2..ccff8f0ace7 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -7,8 +7,21 @@ window.Flash = (function() {
return $(this).fadeOut();
};
- function Flash(message, type, parent) {
- var flash, textDiv;
+ /**
+ * Flash banner supports different types of Flash configurations
+ * along with ability to provide actionConfig which can be used to show
+ * additional action or link on banner next to message
+ *
+ * @param {String} message Flash message
+ * @param {String} type Type of Flash, it can be `notice` or `alert` (default)
+ * @param {Object} parent Reference to Parent element under which Flash needs to appear
+ * @param {Object} actionConfig Map of config to show action on banner
+ * @param {String} href URL to which action link should point (default '#')
+ * @param {String} title Title of action
+ * @param {Function} clickHandler Method to call when action is clicked on
+ */
+ function Flash(message, type, parent, actionConfig) {
+ var flash, textDiv, actionLink;
if (type == null) {
type = 'alert';
}
@@ -30,6 +43,23 @@ window.Flash = (function() {
text: message
});
textDiv.appendTo(flash);
+
+ if (actionConfig) {
+ const actionLinkConfig = {
+ class: 'flash-action',
+ href: actionConfig.href || '#',
+ text: actionConfig.title
+ };
+
+ if (!actionConfig.href) {
+ actionLinkConfig.role = 'button';
+ }
+
+ actionLink = $('<a/>', actionLinkConfig);
+
+ actionLink.appendTo(flash);
+ this.flashContainer.on('click', '.flash-action', actionConfig.clickHandler);
+ }
if (this.flashContainer.parent().hasClass('content-wrapper')) {
textDiv.addClass('container-fluid container-limited');
}
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index f1b99023c72..401dec1a370 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -1,119 +1,34 @@
-/* eslint-disable func-names, space-before-function-paren, no-template-curly-in-string, comma-dangle, object-shorthand, quotes, dot-notation, no-else-return, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-param-reassign, no-useless-escape, prefer-template, consistent-return, wrap-iife, prefer-arrow-callback, camelcase, no-unused-vars, no-useless-return, vars-on-top, max-len */
-
import emojiMap from 'emojis/digests.json';
import emojiAliases from 'emojis/aliases.json';
import { glEmojiTag } from '~/behaviors/gl_emoji';
import glRegexp from '~/lib/utils/regexp';
-
-// Creates the variables for setting up GFM auto-completion
-window.gl = window.gl || {};
+import AjaxCache from '~/lib/utils/ajax_cache';
function sanitize(str) {
return str.replace(/<(?:.|\n)*?>/gm, '');
}
-window.gl.GfmAutoComplete = {
- dataSources: {},
- defaultLoadingData: ['loading'],
- cachedData: {},
- isLoadingData: {},
- atTypeMap: {
- ':': 'emojis',
- '@': 'members',
- '#': 'issues',
- '!': 'mergeRequests',
- '~': 'labels',
- '%': 'milestones',
- '/': 'commands'
- },
- // Emoji
- Emoji: {
- templateFunction: function(name) {
- return `<li>
- ${name} ${glEmojiTag(name)}
- </li>
- `;
- }
- },
- // Team Members
- Members: {
- template: '<li>${avatarTag} ${username} <small>${title}</small></li>'
- },
- Labels: {
- template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>'
- },
- // Issues and MergeRequests
- Issues: {
- template: '<li><small>${id}</small> ${title}</li>'
- },
- // Milestones
- Milestones: {
- template: '<li>${title}</li>'
- },
- Loading: {
- template: '<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>'
- },
- DefaultOptions: {
- sorter: function(query, items, searchKey) {
- this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0;
- if (gl.GfmAutoComplete.isLoading(items)) {
- this.setting.highlightFirst = false;
- return items;
- }
- return $.fn.atwho["default"].callbacks.sorter(query, items, searchKey);
- },
- filter: function(query, data, searchKey) {
- if (gl.GfmAutoComplete.isLoading(data)) {
- gl.GfmAutoComplete.fetchData(this.$inputor, this.at);
- return data;
- } else {
- return $.fn.atwho["default"].callbacks.filter(query, data, searchKey);
- }
- },
- beforeInsert: function(value) {
- if (value && !this.setting.skipSpecialCharacterTest) {
- var withoutAt = value.substring(1);
- if (withoutAt && /[^\w\d]/.test(withoutAt)) value = value.charAt() + '"' + withoutAt + '"';
- }
- return value;
- },
- matcher: function (flag, subtext) {
- // The below is taken from At.js source
- // Tweaked to commands to start without a space only if char before is a non-word character
- // https://github.com/ichord/At.js
- var _a, _y, regexp, match, atSymbolsWithBar, atSymbolsWithoutBar;
- atSymbolsWithBar = Object.keys(this.app.controllers).join('|');
- atSymbolsWithoutBar = Object.keys(this.app.controllers).join('');
- subtext = subtext.split(/\s+/g).pop();
- flag = flag.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
-
- _a = decodeURI("%C3%80");
- _y = decodeURI("%C3%BF");
-
- regexp = new RegExp("^(?:\\B|[^a-zA-Z0-9_" + atSymbolsWithoutBar + "]|\\s)" + flag + "(?!" + atSymbolsWithBar + ")((?:[A-Za-z" + _a + "-" + _y + "0-9_\'\.\+\-]|[^\\x00-\\x7a])*)$", 'gi');
-
- match = regexp.exec(subtext);
+class GfmAutoComplete {
+ constructor(dataSources) {
+ this.dataSources = dataSources || {};
+ this.cachedData = {};
+ this.isLoadingData = {};
+ }
- if (match) {
- return match[1];
- } else {
- return null;
- }
- }
- },
- setup: function(input, enableMap = {
+ setup(input, enableMap = {
emojis: true,
members: true,
issues: true,
milestones: true,
mergeRequests: true,
- labels: 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() {
this.input.each((i, input) => {
const $input = $(input);
@@ -121,10 +36,11 @@ window.gl.GfmAutoComplete = {
// This triggers at.js again
// Needed for slash commands with suffixes (ex: /label ~)
$input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
+ $input.on('clear-commands-cache.atwho', () => this.clearCache());
});
- },
+ }
- setupAtWho: function($input) {
+ setupAtWho($input) {
if (this.enableMap.emojis) this.setupEmoji($input);
if (this.enableMap.members) this.setupMembers($input);
if (this.enableMap.issues) this.setupIssues($input);
@@ -138,10 +54,11 @@ window.gl.GfmAutoComplete = {
alias: 'commands',
searchKey: 'search',
skipSpecialCharacterTest: true,
- data: this.defaultLoadingData,
- displayTpl: function(value) {
- if (this.isLoading(value)) return this.Loading.template;
- var tpl = '<li>/${name}';
+ data: GfmAutoComplete.defaultLoadingData,
+ displayTpl(value) {
+ if (GfmAutoComplete.isLoading(value)) return GfmAutoComplete.Loading.template;
+ // eslint-disable-next-line no-template-curly-in-string
+ let tpl = '<li>/${name}';
if (value.aliases.length > 0) {
tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>';
}
@@ -153,105 +70,106 @@ window.gl.GfmAutoComplete = {
}
tpl += '</li>';
return _.template(tpl)(value);
- }.bind(this),
- insertTpl: function(value) {
- var tpl = "/${name} ";
- var reference_prefix = null;
+ },
+ insertTpl(value) {
+ // eslint-disable-next-line no-template-curly-in-string
+ let tpl = '/${name} ';
+ let referencePrefix = null;
if (value.params.length > 0) {
- reference_prefix = value.params[0][0];
- if (/^[@%~]/.test(reference_prefix)) {
- tpl += '<%- reference_prefix %>';
+ referencePrefix = value.params[0][0];
+ if (/^[@%~]/.test(referencePrefix)) {
+ tpl += '<%- referencePrefix %>';
}
}
- return _.template(tpl)({ reference_prefix: reference_prefix });
+ return _.template(tpl)({ referencePrefix });
},
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;
+ ...this.getDefaultCallbacks(),
+ beforeSave(commands) {
+ if (GfmAutoComplete.isLoading(commands)) return commands;
+ return $.map(commands, (c) => {
+ let search = c.name;
if (c.aliases.length > 0) {
- search = search + " " + c.aliases.join(" ");
+ search = `${search} ${c.aliases.join(' ')}`;
}
return {
name: c.name,
aliases: c.aliases,
params: c.params,
description: c.description,
- search: search
+ search,
};
});
},
- matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) {
- var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi;
- var match = regexp.exec(subtext);
+ matcher(flag, subtext) {
+ const regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi;
+ const match = regexp.exec(subtext);
if (match) {
return match[1];
- } else {
- return null;
}
- }
- }
+ return null;
+ },
+ },
});
- return;
- },
+ }
setupEmoji($input) {
// Emoji
$input.atwho({
at: ':',
- displayTpl: function(value) {
- return value && value.name ? this.Emoji.templateFunction(value.name) : this.Loading.template;
- }.bind(this),
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Loading.template;
+ if (value && value.name) {
+ tmpl = GfmAutoComplete.Emoji.templateFunction(value.name);
+ }
+ return tmpl;
+ },
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: ':${name}:',
skipSpecialCharacterTest: true,
- data: this.defaultLoadingData,
+ data: GfmAutoComplete.defaultLoadingData,
callbacks: {
- sorter: this.DefaultOptions.sorter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- filter: this.DefaultOptions.filter,
-
- matcher: (flag, subtext) => {
+ ...this.getDefaultCallbacks(),
+ matcher(flag, subtext) {
const relevantText = subtext.trim().split(/\s/).pop();
const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi');
const match = regexp.exec(relevantText);
return match && match.length ? match[1] : null;
- }
- }
+ },
+ },
});
- },
+ }
setupMembers($input) {
// Team Members
$input.atwho({
at: '@',
- displayTpl: function(value) {
- return value.username != null ? this.Members.template : this.Loading.template;
- }.bind(this),
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Loading.template;
+ if (value.username != null) {
+ tmpl = GfmAutoComplete.Members.template;
+ }
+ return tmpl;
+ },
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${username}',
searchKey: 'search',
alwaysHighlightFirst: true,
skipSpecialCharacterTest: true,
- data: this.defaultLoadingData,
+ data: GfmAutoComplete.defaultLoadingData,
callbacks: {
- sorter: this.DefaultOptions.sorter,
- filter: this.DefaultOptions.filter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- matcher: this.DefaultOptions.matcher,
- beforeSave: function(members) {
- return $.map(members, function(m) {
+ ...this.getDefaultCallbacks(),
+ beforeSave(members) {
+ return $.map(members, (m) => {
let title = '';
if (m.username == null) {
return m;
}
title = m.name;
if (m.count) {
- title += " (" + m.count + ")";
+ title += ` (${m.count})`;
}
const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase();
@@ -262,173 +180,278 @@ window.gl.GfmAutoComplete = {
username: m.username,
avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar,
title: sanitize(title),
- search: sanitize(m.username + " " + m.name)
+ search: sanitize(`${m.username} ${m.name}`),
};
});
- }
- }
+ },
+ },
});
- },
+ }
setupIssues($input) {
$input.atwho({
at: '#',
alias: 'issues',
searchKey: 'search',
- displayTpl: function(value) {
- return value.title != null ? this.Issues.template : this.Loading.template;
- }.bind(this),
- data: this.defaultLoadingData,
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Loading.template;
+ if (value.title != null) {
+ tmpl = GfmAutoComplete.Issues.template;
+ }
+ return tmpl;
+ },
+ data: GfmAutoComplete.defaultLoadingData,
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${id}',
callbacks: {
- sorter: this.DefaultOptions.sorter,
- filter: this.DefaultOptions.filter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- matcher: this.DefaultOptions.matcher,
- beforeSave: function(issues) {
- return $.map(issues, function(i) {
+ ...this.getDefaultCallbacks(),
+ beforeSave(issues) {
+ return $.map(issues, (i) => {
if (i.title == null) {
return i;
}
return {
id: i.iid,
title: sanitize(i.title),
- search: i.iid + " " + i.title
+ search: `${i.iid} ${i.title}`,
};
});
- }
- }
+ },
+ },
});
- },
+ }
setupMilestones($input) {
$input.atwho({
at: '%',
alias: 'milestones',
searchKey: 'search',
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${title}',
- displayTpl: function(value) {
- return value.title != null ? this.Milestones.template : this.Loading.template;
- }.bind(this),
- data: this.defaultLoadingData,
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Loading.template;
+ if (value.title != null) {
+ tmpl = GfmAutoComplete.Milestones.template;
+ }
+ return tmpl;
+ },
+ data: GfmAutoComplete.defaultLoadingData,
callbacks: {
- matcher: this.DefaultOptions.matcher,
- sorter: this.DefaultOptions.sorter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- filter: this.DefaultOptions.filter,
- beforeSave: function(milestones) {
- return $.map(milestones, function(m) {
+ ...this.getDefaultCallbacks(),
+ beforeSave(milestones) {
+ return $.map(milestones, (m) => {
if (m.title == null) {
return m;
}
return {
id: m.iid,
title: sanitize(m.title),
- search: "" + m.title
+ search: m.title,
};
});
- }
- }
+ },
+ },
});
- },
+ }
setupMergeRequests($input) {
$input.atwho({
at: '!',
alias: 'mergerequests',
searchKey: 'search',
- displayTpl: function(value) {
- return value.title != null ? this.Issues.template : this.Loading.template;
- }.bind(this),
- data: this.defaultLoadingData,
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Loading.template;
+ if (value.title != null) {
+ tmpl = GfmAutoComplete.Issues.template;
+ }
+ return tmpl;
+ },
+ data: GfmAutoComplete.defaultLoadingData,
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${id}',
callbacks: {
- sorter: this.DefaultOptions.sorter,
- filter: this.DefaultOptions.filter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- matcher: this.DefaultOptions.matcher,
- beforeSave: function(merges) {
- return $.map(merges, function(m) {
+ ...this.getDefaultCallbacks(),
+ beforeSave(merges) {
+ return $.map(merges, (m) => {
if (m.title == null) {
return m;
}
return {
id: m.iid,
title: sanitize(m.title),
- search: m.iid + " " + m.title
+ search: `${m.iid} ${m.title}`,
};
});
- }
- }
+ },
+ },
});
- },
+ }
setupLabels($input) {
$input.atwho({
at: '~',
alias: 'labels',
searchKey: 'search',
- data: this.defaultLoadingData,
- displayTpl: function(value) {
- return this.isLoading(value) ? this.Loading.template : this.Labels.template;
- }.bind(this),
+ data: GfmAutoComplete.defaultLoadingData,
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Labels.template;
+ if (GfmAutoComplete.isLoading(value)) {
+ tmpl = GfmAutoComplete.Loading.template;
+ }
+ return tmpl;
+ },
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${title}',
callbacks: {
- matcher: this.DefaultOptions.matcher,
- beforeInsert: this.DefaultOptions.beforeInsert,
- filter: this.DefaultOptions.filter,
- sorter: this.DefaultOptions.sorter,
- beforeSave: function(merges) {
- if (gl.GfmAutoComplete.isLoading(merges)) return merges;
- var sanitizeLabelTitle;
- sanitizeLabelTitle = function(title) {
- if (/[\w\?&]+\s+[\w\?&]+/g.test(title)) {
- return "\"" + (sanitize(title)) + "\"";
- } else {
- return sanitize(title);
- }
- };
- return $.map(merges, function(m) {
- return {
- title: sanitize(m.title),
- color: m.color,
- search: "" + m.title
- };
- });
- }
- }
+ ...this.getDefaultCallbacks(),
+ beforeSave(merges) {
+ if (GfmAutoComplete.isLoading(merges)) return merges;
+ return $.map(merges, m => ({
+ title: sanitize(m.title),
+ color: m.color,
+ search: m.title,
+ }));
+ },
+ },
});
- },
+ }
+
+ getDefaultCallbacks() {
+ const fetchData = this.fetchData.bind(this);
+
+ return {
+ sorter(query, items, searchKey) {
+ this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0;
+ if (GfmAutoComplete.isLoading(items)) {
+ this.setting.highlightFirst = false;
+ return items;
+ }
+ return $.fn.atwho.default.callbacks.sorter(query, items, searchKey);
+ },
+ filter(query, data, searchKey) {
+ if (GfmAutoComplete.isLoading(data)) {
+ fetchData(this.$inputor, this.at);
+ return data;
+ }
+ return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
+ },
+ beforeInsert(value) {
+ let resultantValue = value;
+ if (value && !this.setting.skipSpecialCharacterTest) {
+ const withoutAt = value.substring(1);
+ if (withoutAt && /[^\w\d]/.test(withoutAt)) {
+ resultantValue = `${value.charAt()}"${withoutAt}"`;
+ }
+ }
+ return resultantValue;
+ },
+ matcher(flag, subtext) {
+ // The below is taken from At.js source
+ // Tweaked to commands to start without a space only if char before is a non-word character
+ // https://github.com/ichord/At.js
+ const atSymbolsWithBar = Object.keys(this.app.controllers).join('|');
+ const atSymbolsWithoutBar = Object.keys(this.app.controllers).join('');
+ const targetSubtext = subtext.split(/\s+/g).pop();
+ const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
- fetchData: function($input, at) {
+ const accentAChar = decodeURI('%C3%80');
+ const accentYChar = decodeURI('%C3%BF');
+
+ const regexp = new RegExp(`^(?:\\B|[^a-zA-Z0-9_${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-]|[^\\x00-\\x7a])*)$`, 'gi');
+
+ const match = regexp.exec(targetSubtext);
+
+ if (match) {
+ return match[1];
+ }
+ return null;
+ },
+ };
+ }
+
+ fetchData($input, at) {
if (this.isLoadingData[at]) return;
this.isLoadingData[at] = true;
if (this.cachedData[at]) {
this.loadData($input, at, this.cachedData[at]);
- } else if (this.atTypeMap[at] === 'emojis') {
+ } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
this.loadData($input, at, Object.keys(emojiMap).concat(Object.keys(emojiAliases)));
} else {
- $.getJSON(this.dataSources[this.atTypeMap[at]], (data) => {
- this.loadData($input, at, data);
- }).fail(() => { this.isLoadingData[at] = false; });
+ AjaxCache.retrieve(this.dataSources[GfmAutoComplete.atTypeMap[at]], true)
+ .then((data) => {
+ this.loadData($input, at, data);
+ })
+ .catch(() => { this.isLoadingData[at] = false; });
}
- },
- loadData: function($input, at, data) {
+ }
+
+ loadData($input, at, data) {
this.isLoadingData[at] = false;
this.cachedData[at] = data;
$input.atwho('load', at, data);
// This trigger at.js again
// otherwise we would be stuck with loading until the user types
return $input.trigger('keyup');
- },
- isLoading(data) {
- var dataToInspect = data;
+ }
+
+ clearCache() {
+ this.cachedData = {};
+ }
+
+ static isLoading(data) {
+ let dataToInspect = data;
if (data && data.length > 0) {
dataToInspect = data[0];
}
- var loadingState = this.defaultLoadingData[0];
+ const loadingState = GfmAutoComplete.defaultLoadingData[0];
return dataToInspect &&
(dataToInspect === loadingState || dataToInspect.name === loadingState);
}
+}
+
+GfmAutoComplete.defaultLoadingData = ['loading'];
+
+GfmAutoComplete.atTypeMap = {
+ ':': 'emojis',
+ '@': 'members',
+ '#': 'issues',
+ '!': 'mergeRequests',
+ '~': 'labels',
+ '%': 'milestones',
+ '/': 'commands',
+};
+
+// Emoji
+GfmAutoComplete.Emoji = {
+ templateFunction(name) {
+ return `<li>
+ ${name} ${glEmojiTag(name)}
+ </li>
+ `;
+ },
+};
+// Team Members
+GfmAutoComplete.Members = {
+ // eslint-disable-next-line no-template-curly-in-string
+ template: '<li>${avatarTag} ${username} <small>${title}</small></li>',
+};
+GfmAutoComplete.Labels = {
+ // eslint-disable-next-line no-template-curly-in-string
+ template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>',
+};
+// Issues and MergeRequests
+GfmAutoComplete.Issues = {
+ // eslint-disable-next-line no-template-curly-in-string
+ template: '<li><small>${id}</small> ${title}</li>',
};
+// Milestones
+GfmAutoComplete.Milestones = {
+ // eslint-disable-next-line no-template-curly-in-string
+ template: '<li>${title}</li>',
+};
+GfmAutoComplete.Loading = {
+ template: '<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>',
+};
+
+export default GfmAutoComplete;
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index 6227722b99b..d34561e5512 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -1,9 +1,8 @@
/* eslint-disable func-names, space-before-function-paren, no-var, one-var, one-var-declaration-per-line, prefer-rest-params, max-len, vars-on-top, wrap-iife, no-unused-vars, quotes, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, comma-dangle, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func, no-mixed-operators */
/* global fuzzaldrinPlus */
+import { isObject } from './lib/utils/type_utility';
-var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote,
- bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
- indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; };
+var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote;
GitLabDropdownFilter = (function() {
var ARROW_KEY_CODES, BLUR_KEYCODES, HAS_VALUE_CLASS;
@@ -95,7 +94,7 @@ GitLabDropdownFilter = (function() {
// { prop: 'def' }
// ]
// }
- if (gl.utils.isObject(data)) {
+ if (isObject(data)) {
results = {};
for (key in data) {
group = data[key];
@@ -213,10 +212,10 @@ GitLabDropdown = (function() {
var searchFields, selector, self;
this.el = el1;
this.options = options;
- this.updateLabel = bind(this.updateLabel, this);
- this.hidden = bind(this.hidden, this);
- this.opened = bind(this.opened, this);
- this.shouldPropagate = bind(this.shouldPropagate, this);
+ this.updateLabel = this.updateLabel.bind(this);
+ this.hidden = this.hidden.bind(this);
+ this.opened = this.opened.bind(this);
+ this.shouldPropagate = this.shouldPropagate.bind(this);
self = this;
selector = $(this.el).data("target");
this.dropdown = selector != null ? $(selector) : $(this.el).parent();
@@ -398,7 +397,7 @@ GitLabDropdown = (function() {
html = [this.noResults()];
} else {
// Handle array groups
- if (gl.utils.isObject(data)) {
+ if (isObject(data)) {
html = [];
for (name in data) {
groupData = data[name];
@@ -469,8 +468,8 @@ GitLabDropdown = (function() {
// Process the data to make sure rendered data
// matches the correct layout
- if (this.fullData && hasMultiSelect && this.options.processData) {
- const inputValue = this.filterInput.val();
+ const inputValue = this.filterInput.val();
+ if (this.fullData && hasMultiSelect && this.options.processData && inputValue.length === 0) {
this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this));
}
@@ -632,8 +631,8 @@ GitLabDropdown = (function() {
};
GitLabDropdown.prototype.highlightTextMatches = function(text, term) {
- var occurrences;
- occurrences = fuzzaldrinPlus.match(text, term);
+ const occurrences = fuzzaldrinPlus.match(text, term);
+ const indexOf = [].indexOf;
return text.split('').map(function(character, i) {
if (indexOf.call(occurrences, i) !== -1) {
return "<b>" + character + "</b>";
@@ -741,6 +740,12 @@ GitLabDropdown = (function() {
$input.attr('id', this.options.inputId);
}
+ if (this.options.multiSelect) {
+ Object.keys(selectedObject).forEach((attribute) => {
+ $input.attr(`data-${attribute}`, selectedObject[attribute]);
+ });
+ }
+
if (this.options.inputMeta) {
$input.attr('data-meta', selectedObject[this.options.inputMeta]);
}
diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js
index ca3cec07a88..4bef60264bb 100644
--- a/app/assets/javascripts/gl_field_errors.js
+++ b/app/assets/javascripts/gl_field_errors.js
@@ -1,6 +1,6 @@
/* eslint-disable comma-dangle, class-methods-use-this, max-len, space-before-function-paren, arrow-parens, no-param-reassign */
-require('./gl_field_error');
+import './gl_field_error';
const customValidationFlag = 'gl-field-error-ignore';
@@ -31,9 +31,13 @@ class GlFieldErrors {
* and prevents disabling of invalid submit button by application.js */
catchInvalidFormSubmit (event) {
- if (!event.currentTarget.checkValidity()) {
- event.preventDefault();
- event.stopPropagation();
+ const $form = $(event.currentTarget);
+
+ if (!$form.attr('novalidate')) {
+ if (!event.currentTarget.checkValidity()) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
}
}
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index ff06092e4d6..dc9f114af99 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -3,11 +3,14 @@
/* global DropzoneInput */
/* global autosize */
+import GfmAutoComplete from './gfm_auto_complete';
+
window.gl = window.gl || {};
-function GLForm(form) {
+function GLForm(form, enableGFM = false) {
this.form = form;
this.textarea = this.form.find('textarea.js-gfm-input');
+ this.enableGFM = enableGFM;
// Before we start, we should clean up any previous data for this form
this.destroy();
// Setup the form
@@ -30,8 +33,14 @@ GLForm.prototype.setupForm = function() {
this.form.addClass('gfm-form');
// remove notify commit author checkbox for non-commit notes
gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion'));
-
- gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input'));
+ new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup(this.form.find('.js-gfm-input'), {
+ emojis: true,
+ members: this.enableGFM,
+ issues: this.enableGFM,
+ milestones: this.enableGFM,
+ mergeRequests: this.enableGFM,
+ labels: this.enableGFM,
+ });
new DropzoneInput(this.form);
autosize(this.textarea);
}
diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js
index 521bc77db66..0deb27e522b 100644
--- a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js
+++ b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js
@@ -2,7 +2,6 @@
import d3 from 'd3';
-const bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
const extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
const hasProp = {}.hasOwnProperty;
@@ -95,7 +94,7 @@ export const ContributorsMasterGraph = (function(superClass) {
function ContributorsMasterGraph(data1) {
this.data = data1;
- this.update_content = bind(this.update_content, this);
+ this.update_content = this.update_content.bind(this);
this.width = $('.content').width() - 70;
this.height = 200;
this.x = null;
diff --git a/app/assets/javascripts/group_name.js b/app/assets/javascripts/group_name.js
index 62675d7e67e..462d792b8d5 100644
--- a/app/assets/javascripts/group_name.js
+++ b/app/assets/javascripts/group_name.js
@@ -44,18 +44,18 @@ export default class GroupName {
showToggle() {
this.title.classList.add('wrap');
this.toggle.classList.remove('hidden');
- if (this.isHidden) this.groupTitle.classList.add('is-hidden');
+ if (this.isHidden) this.groupTitle.classList.add('hidden');
}
hideToggle() {
this.title.classList.remove('wrap');
this.toggle.classList.add('hidden');
- if (this.isHidden) this.groupTitle.classList.remove('is-hidden');
+ if (this.isHidden) this.groupTitle.classList.remove('hidden');
}
toggleGroups() {
this.isHidden = !this.isHidden;
- this.groupTitle.classList.toggle('is-hidden');
+ this.groupTitle.classList.toggle('hidden');
}
render() {
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
index acfa4bd4c6b..b5975295329 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -3,7 +3,7 @@
prefer-arrow-callback, comma-dangle, consistent-return, yoda,
prefer-rest-params, prefer-spread, no-unused-vars, prefer-template,
promise/catch-or-return */
-/* global Api */
+import Api from './api';
var slice = [].slice;
diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js
index 34e4a257ff9..5b4ca94ed30 100644
--- a/app/assets/javascripts/importer_status.js
+++ b/app/assets/javascripts/importer_status.js
@@ -56,6 +56,8 @@
if (job.import_status === 'finished') {
job_item.removeClass("active").addClass("success");
return status_field.html('<span><i class="fa fa-check"></i> done</span>');
+ } else if (job.import_status === 'scheduled') {
+ return status_field.html("<i class='fa fa-spinner fa-spin'></i> scheduled");
} else if (job.import_status === 'started') {
return status_field.html("<i class='fa fa-spinner fa-spin'></i> started");
} else {
diff --git a/app/assets/javascripts/integrations/index.js b/app/assets/javascripts/integrations/index.js
new file mode 100644
index 00000000000..10fe6bac0e8
--- /dev/null
+++ b/app/assets/javascripts/integrations/index.js
@@ -0,0 +1,7 @@
+/* eslint-disable no-new */
+import IntegrationSettingsForm from './integration_settings_form';
+
+$(() => {
+ const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ integrationSettingsForm.init();
+});
diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js
new file mode 100644
index 00000000000..ddd3a6aab99
--- /dev/null
+++ b/app/assets/javascripts/integrations/integration_settings_form.js
@@ -0,0 +1,123 @@
+/* global Flash */
+
+export default class IntegrationSettingsForm {
+ constructor(formSelector) {
+ this.$form = $(formSelector);
+
+ // Form Metadata
+ this.canTestService = this.$form.data('can-test');
+ this.testEndPoint = this.$form.data('test-url');
+
+ // Form Child Elements
+ this.$serviceToggle = this.$form.find('#service_active');
+ this.$submitBtn = this.$form.find('button[type="submit"]');
+ this.$submitBtnLoader = this.$submitBtn.find('.js-btn-spinner');
+ this.$submitBtnLabel = this.$submitBtn.find('.js-btn-label');
+ }
+
+ init() {
+ // Initialize View
+ this.toggleServiceState(this.$serviceToggle.is(':checked'));
+
+ // Bind Event Listeners
+ this.$serviceToggle.on('change', e => this.handleServiceToggle(e));
+ this.$submitBtn.on('click', e => this.handleSettingsSave(e));
+ }
+
+ handleSettingsSave(e) {
+ // Check if Service is marked active, as if not marked active,
+ // We can skip testing it and directly go ahead to allow form to
+ // be submitted
+ if (!this.$serviceToggle.is(':checked')) {
+ return;
+ }
+
+ // Service was marked active so now we check;
+ // 1) If form contents are valid
+ // 2) If this service can be tested
+ // If both conditions are true, we override form submission
+ // and test the service using provided configuration.
+ if (this.$form.get(0).checkValidity() && this.canTestService) {
+ e.preventDefault();
+ this.testSettings(this.$form.serialize());
+ }
+ }
+
+ handleServiceToggle(e) {
+ this.toggleServiceState($(e.currentTarget).is(':checked'));
+ }
+
+ /**
+ * Change Form's validation enforcement based on service status (active/inactive)
+ */
+ toggleServiceState(serviceActive) {
+ this.toggleSubmitBtnLabel(serviceActive);
+ if (serviceActive) {
+ this.$form.removeAttr('novalidate');
+ } else if (!this.$form.attr('novalidate')) {
+ this.$form.attr('novalidate', 'novalidate');
+ }
+ }
+
+ /**
+ * Toggle Submit button label based on Integration status and ability to test service
+ */
+ toggleSubmitBtnLabel(serviceActive) {
+ let btnLabel = 'Save changes';
+
+ if (serviceActive && this.canTestService) {
+ btnLabel = 'Test settings and save changes';
+ }
+
+ this.$submitBtnLabel.text(btnLabel);
+ }
+
+ /**
+ * Toggle Submit button state based on provided boolean value of `saveTestActive`
+ * When enabled, it does two things, and reverts back when disabled
+ *
+ * 1. It shows load spinner on submit button
+ * 2. Makes submit button disabled
+ */
+ toggleSubmitBtnState(saveTestActive) {
+ if (saveTestActive) {
+ this.$submitBtn.disable();
+ this.$submitBtnLoader.removeClass('hidden');
+ } else {
+ this.$submitBtn.enable();
+ this.$submitBtnLoader.addClass('hidden');
+ }
+ }
+
+ /* eslint-disable promise/catch-or-return, no-new */
+ /**
+ * Test Integration config
+ */
+ testSettings(formData) {
+ this.toggleSubmitBtnState(true);
+ $.ajax({
+ type: 'PUT',
+ url: this.testEndPoint,
+ data: formData,
+ })
+ .done((res) => {
+ if (res.error) {
+ new Flash(res.message, null, null, {
+ title: 'Save anyway',
+ clickHandler: (e) => {
+ e.preventDefault();
+ this.$form.submit();
+ },
+ });
+ } else {
+ this.$form.submit();
+ }
+ })
+ .fail(() => {
+ new Flash('Something went wrong on our end.');
+ })
+ .always(() => {
+ this.toggleSubmitBtnState(false);
+ });
+ }
+}
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
new file mode 100644
index 00000000000..e46c0e90255
--- /dev/null
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -0,0 +1,159 @@
+/* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, space-before-function-paren, prefer-arrow-callback, max-len, no-unused-expressions, no-sequences, no-underscore-dangle, no-unused-vars, no-param-reassign */
+/* global IssuableIndex */
+/* global Flash */
+
+export default {
+ init({ container, form, issues, prefixId } = {}) {
+ this.prefixId = prefixId || 'issue_';
+ this.form = form || this.getElement('.bulk-update');
+ this.$labelDropdown = this.form.find('.js-label-select');
+ this.issues = issues || this.getElement('.issues-list .issue');
+ this.willUpdateLabels = false;
+ this.bindEvents();
+ },
+
+ bindEvents() {
+ return this.form.off('submit').on('submit', this.onFormSubmit.bind(this));
+ },
+
+ onFormSubmit(e) {
+ e.preventDefault();
+ return this.submit();
+ },
+
+ submit() {
+ const _this = this;
+ const xhr = $.ajax({
+ url: this.form.attr('action'),
+ method: this.form.attr('method'),
+ dataType: 'JSON',
+ data: this.getFormDataAsObject()
+ });
+ xhr.done(() => window.location.reload());
+ xhr.fail(() => this.onFormSubmitFailure());
+ },
+
+ onFormSubmitFailure() {
+ this.form.find('[type="submit"]').enable();
+ return new Flash("Issue update failed");
+ },
+
+ getSelectedIssues() {
+ return this.issues.has('.selected_issue:checked');
+ },
+
+ getLabelsFromSelection() {
+ const labels = [];
+ this.getSelectedIssues().map(function() {
+ const labelsData = $(this).data('labels');
+ if (labelsData) {
+ return labelsData.map(function(labelId) {
+ if (labels.indexOf(labelId) === -1) {
+ return labels.push(labelId);
+ }
+ });
+ }
+ });
+ return labels;
+ },
+
+ /**
+ * Will return only labels that were marked previously and the user has unmarked
+ * @return {Array} Label IDs
+ */
+
+ getUnmarkedIndeterminedLabels() {
+ const result = [];
+ const labelsToKeep = this.$labelDropdown.data('indeterminate');
+
+ this.getLabelsFromSelection().forEach((id) => {
+ if (labelsToKeep.indexOf(id) === -1) {
+ result.push(id);
+ }
+ });
+
+ return result;
+ },
+
+ /**
+ * Simple form serialization, it will return just what we need
+ * Returns key/value pairs from form data
+ */
+
+ getFormDataAsObject() {
+ 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(),
+ add_label_ids: [],
+ remove_label_ids: []
+ }
+ };
+ if (this.willUpdateLabels) {
+ formData.update.add_label_ids = this.$labelDropdown.data('marked');
+ formData.update.remove_label_ids = this.$labelDropdown.data('unmarked');
+ }
+ return formData;
+ },
+
+ setOriginalDropdownData() {
+ const $labelSelect = $('.bulk-update .js-label-select');
+ $labelSelect.data('common', this.getOriginalCommonIds());
+ $labelSelect.data('marked', this.getOriginalMarkedIds());
+ $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds());
+ },
+
+ // From issuable's initial bulk selection
+ getOriginalCommonIds() {
+ const labelIds = [];
+
+ this.getElement('.selected_issue:checked').each((i, el) => {
+ labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
+ });
+ return _.intersection.apply(this, labelIds);
+ },
+
+ // From issuable's initial bulk selection
+ getOriginalMarkedIds() {
+ const labelIds = [];
+ this.getElement('.selected_issue:checked').each((i, el) => {
+ labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
+ });
+ return _.intersection.apply(this, labelIds);
+ },
+
+ // From issuable's initial bulk selection
+ getOriginalIndeterminateIds() {
+ const uniqueIds = [];
+ const labelIds = [];
+ let issuableLabels = [];
+
+ // Collect unique label IDs for all checked issues
+ this.getElement('.selected_issue:checked').each((i, el) => {
+ issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels');
+ issuableLabels.forEach((labelId) => {
+ // Store unique IDs
+ if (uniqueIds.indexOf(labelId) === -1) {
+ uniqueIds.push(labelId);
+ }
+ });
+ // Store array of IDs per issuable
+ labelIds.push(issuableLabels);
+ });
+ // Add uniqueIds to add it as argument for _.intersection
+ labelIds.unshift(uniqueIds);
+ // Return IDs that are present but not in all selected issueables
+ return _.difference(uniqueIds, _.intersection.apply(this, labelIds));
+ },
+
+ getElement(selector) {
+ this.scopeEl = this.scopeEl || $('.content');
+ return this.scopeEl.find(selector);
+ },
+};
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js
new file mode 100644
index 00000000000..84bd2e092e6
--- /dev/null
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js
@@ -0,0 +1,165 @@
+/* eslint-disable class-methods-use-this, no-new */
+/* global LabelsSelect */
+/* global MilestoneSelect */
+/* global IssueStatusSelect */
+/* global SubscriptionSelect */
+
+import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
+
+const HIDDEN_CLASS = 'hidden';
+const DISABLED_CONTENT_CLASS = 'disabled-content';
+const SIDEBAR_EXPANDED_CLASS = 'right-sidebar-expanded issuable-bulk-update-sidebar';
+const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-sidebar';
+
+export default class IssuableBulkUpdateSidebar {
+ constructor() {
+ this.initDomElements();
+ this.bindEvents();
+ this.initDropdowns();
+ this.setupBulkUpdateActions();
+ }
+
+ initDomElements() {
+ this.$page = $('.page-with-sidebar');
+ this.$sidebar = $('.right-sidebar');
+ this.$bulkEditCancelBtn = $('.js-bulk-update-menu-hide');
+ this.$bulkEditSubmitBtn = $('.update-selected-issues');
+ this.$bulkUpdateEnableBtn = $('.js-bulk-update-toggle');
+ this.$otherFilters = $('.issues-other-filters');
+ this.$checkAllContainer = $('.check-all-holder');
+ this.$issueChecks = $('.issue-check');
+ this.$issuesList = $('.selected_issue');
+ this.$issuableIdsInput = $('#update_issuable_ids');
+ }
+
+ bindEvents() {
+ this.$bulkUpdateEnableBtn.on('click', e => this.toggleBulkEdit(e, true));
+ this.$bulkEditCancelBtn.on('click', e => this.toggleBulkEdit(e, false));
+ this.$checkAllContainer.on('click', e => this.selectAll(e));
+ this.$issuesList.on('change', () => this.updateFormState());
+ this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit());
+ this.$checkAllContainer.on('click', () => this.updateFormState());
+ }
+
+ initDropdowns() {
+ new LabelsSelect();
+ new MilestoneSelect();
+ new IssueStatusSelect();
+ new SubscriptionSelect();
+ }
+
+ getNavHeight() {
+ const navbarHeight = $('.navbar-gitlab').outerHeight();
+ const layoutNavHeight = $('.layout-nav').outerHeight();
+ const subNavScroll = $('.sub-nav-scroll').outerHeight();
+ return navbarHeight + layoutNavHeight + subNavScroll;
+ }
+
+ initSidebar() {
+ if (!this.navHeight) {
+ this.navHeight = this.getNavHeight();
+ }
+
+ if (!this.sidebarInitialized) {
+ $(document).off('scroll').on('scroll', _.throttle(this.setSidebarHeight, 10).bind(this));
+ $(window).off('resize').on('resize', _.throttle(this.setSidebarHeight, 10).bind(this));
+ this.sidebarInitialized = true;
+ }
+ }
+
+ setupBulkUpdateActions() {
+ IssuableBulkUpdateActions.setOriginalDropdownData();
+ }
+
+ updateFormState() {
+ const noCheckedIssues = !$('.selected_issue:checked').length;
+
+ this.toggleSubmitButtonDisabled(noCheckedIssues);
+ this.updateSelectedIssuableIds();
+
+ IssuableBulkUpdateActions.setOriginalDropdownData();
+ }
+
+ prepForSubmit() {
+ // if submit button is disabled, submission is blocked. This ensures we disable after
+ // form submission is carried out
+ setTimeout(() => this.$bulkEditSubmitBtn.disable());
+ this.updateSelectedIssuableIds();
+ }
+
+ toggleBulkEdit(e, enable) {
+ e.preventDefault();
+
+ this.toggleSidebarDisplay(enable);
+ this.toggleBulkEditButtonDisabled(enable);
+ this.toggleOtherFiltersDisabled(enable);
+ this.toggleCheckboxDisplay(enable);
+
+ if (enable) {
+ this.initSidebar();
+ }
+ }
+
+ updateSelectedIssuableIds() {
+ this.$issuableIdsInput.val(IssuableBulkUpdateSidebar.getCheckedIssueIds());
+ }
+
+ selectAll() {
+ const checkAllButtonState = this.$checkAllContainer.find('input').prop('checked');
+
+ this.$issuesList.prop('checked', checkAllButtonState);
+ }
+
+ toggleSidebarDisplay(show) {
+ this.$page.toggleClass(SIDEBAR_EXPANDED_CLASS, show);
+ this.$page.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show);
+ this.$sidebar.toggleClass(SIDEBAR_EXPANDED_CLASS, show);
+ this.$sidebar.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show);
+ }
+
+ toggleBulkEditButtonDisabled(disable) {
+ if (disable) {
+ this.$bulkUpdateEnableBtn.disable();
+ } else {
+ this.$bulkUpdateEnableBtn.enable();
+ }
+ }
+
+ toggleCheckboxDisplay(show) {
+ this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show);
+ this.$issueChecks.toggleClass(HIDDEN_CLASS, !show);
+ }
+
+ toggleOtherFiltersDisabled(disable) {
+ this.$otherFilters.toggleClass(DISABLED_CONTENT_CLASS, disable);
+ }
+
+ toggleSubmitButtonDisabled(disable) {
+ if (disable) {
+ this.$bulkEditSubmitBtn.disable();
+ } else {
+ this.$bulkEditSubmitBtn.enable();
+ }
+ }
+ // loosely based on method of the same name in right_sidebar.js
+ setSidebarHeight() {
+ const currentScrollDepth = window.pageYOffset || 0;
+ const diff = this.navHeight - currentScrollDepth;
+
+ if (diff > 0) {
+ this.$sidebar.outerHeight(window.innerHeight - diff);
+ } else {
+ this.$sidebar.outerHeight('100%');
+ }
+ }
+
+ static getCheckedIssueIds() {
+ const $checkedIssues = $('.selected_issue:checked');
+
+ if ($checkedIssues.length > 0) {
+ return $.map($checkedIssues, value => $(value).data('id'));
+ }
+
+ return [];
+ }
+}
diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js
index 834b98e8601..a4d7bf096ef 100644
--- a/app/assets/javascripts/issuable_context.js
+++ b/app/assets/javascripts/issuable_context.js
@@ -1,8 +1,8 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-new, comma-dangle, quotes, prefer-arrow-callback, consistent-return, one-var, no-var, one-var-declaration-per-line, no-underscore-dangle, max-len */
-/* global UsersSelect */
/* global bp */
import Cookies from 'js-cookie';
+import UsersSelect from './users_select';
(function() {
this.IssuableContext = (function() {
@@ -47,7 +47,6 @@ import Cookies from 'js-cookie';
Cookies.set('collapsed_gutter', true);
}
});
- $(".right-sidebar").niceScroll();
}
IssuableContext.prototype.initParticipants = function() {
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index 687c2bb6110..92f6f0d4117 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -1,14 +1,14 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-useless-escape, no-new, quotes, object-shorthand, no-unused-vars, comma-dangle, no-alert, consistent-return, no-else-return, prefer-template, one-var, one-var-declaration-per-line, curly, max-len */
/* global GitLab */
-/* global UsersSelect */
/* global ZenMode */
/* global Autosave */
/* global dateFormat */
/* global Pikaday */
-(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
+import UsersSelect from './users_select';
+import GfmAutoComplete from './gfm_auto_complete';
+(function() {
this.IssuableForm = (function() {
IssuableForm.prototype.issueMoveConfirmMsg = 'Are you sure you want to move this issue to another project?';
@@ -17,11 +17,11 @@
function IssuableForm(form) {
var $issuableDueDate, calendar;
this.form = form;
- this.toggleWip = bind(this.toggleWip, this);
- this.renderWipExplanation = bind(this.renderWipExplanation, this);
- this.resetAutosave = bind(this.resetAutosave, this);
- this.handleSubmit = bind(this.handleSubmit, this);
- gl.GfmAutoComplete.setup();
+ this.toggleWip = this.toggleWip.bind(this);
+ this.renderWipExplanation = this.renderWipExplanation.bind(this);
+ this.resetAutosave = this.resetAutosave.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup();
new UsersSelect();
new ZenMode();
this.titleField = this.form.find("input[name*='[title]']");
diff --git a/app/assets/javascripts/issuable.js b/app/assets/javascripts/issuable_index.js
index 3bfce32768a..5c96646def8 100644
--- a/app/assets/javascripts/issuable.js
+++ b/app/assets/javascripts/issuable_index.js
@@ -1,30 +1,33 @@
/* eslint-disable no-param-reassign, func-names, no-var, camelcase, no-unused-vars, object-shorthand, space-before-function-paren, no-return-assign, comma-dangle, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, prefer-arrow-callback, wrap-iife, max-len */
-/* global Issuable */
+/* global IssuableIndex */
+
+import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar';
+import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
((global) => {
var issuable_created;
issuable_created = false;
- global.Issuable = {
- init: function() {
- Issuable.initTemplates();
- Issuable.initSearch();
- Issuable.initChecks();
- Issuable.initResetFilters();
- Issuable.resetIncomingEmailToken();
- return Issuable.initLabelFilterRemove();
+ global.IssuableIndex = {
+ init: function(pagePrefix) {
+ IssuableIndex.initTemplates();
+ IssuableIndex.initSearch();
+ IssuableIndex.initBulkUpdate(pagePrefix);
+ IssuableIndex.initResetFilters();
+ IssuableIndex.resetIncomingEmailToken();
+ IssuableIndex.initLabelFilterRemove();
},
initTemplates: function() {
- return Issuable.labelRow = _.template('<% _.each(labels, function(label){ %> <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> <%- label.title %> </a> <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> <i class="fa fa-times"></i> </button> </span> <% }); %>');
+ return IssuableIndex.labelRow = _.template('<% _.each(labels, function(label){ %> <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> <%- label.title %> </a> <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> <i class="fa fa-times"></i> </button> </span> <% }); %>');
},
initSearch: function() {
const $searchInput = $('#issuable_search');
- Issuable.initSearchState($searchInput);
+ IssuableIndex.initSearchState($searchInput);
// `immediate` param set to false debounces on the `trailing` edge, lets user finish typing
- const debouncedExecSearch = _.debounce(Issuable.executeSearch, 1000, false);
+ const debouncedExecSearch = _.debounce(IssuableIndex.executeSearch, 1000, false);
$searchInput.off('keyup').on('keyup', debouncedExecSearch);
@@ -37,16 +40,16 @@
initSearchState: function($searchInput) {
const currentSearchVal = $searchInput.val();
- Issuable.searchState = {
+ IssuableIndex.searchState = {
elem: $searchInput,
current: currentSearchVal
};
- Issuable.maybeFocusOnSearch();
+ IssuableIndex.maybeFocusOnSearch();
},
accessSearchPristine: function(set) {
// store reference to previous value to prevent search on non-mutating keyup
- const state = Issuable.searchState;
+ const state = IssuableIndex.searchState;
const currentSearchVal = state.elem.val();
if (set) {
@@ -56,10 +59,10 @@
}
},
maybeFocusOnSearch: function() {
- const currentSearchVal = Issuable.searchState.current;
+ const currentSearchVal = IssuableIndex.searchState.current;
if (currentSearchVal && currentSearchVal !== '') {
const queryLength = currentSearchVal.length;
- const $searchInput = Issuable.searchState.elem;
+ const $searchInput = IssuableIndex.searchState.elem;
/* The following ensures that the cursor is initially placed at
* the end of search input when focus is applied. It accounts
@@ -80,7 +83,7 @@
const $searchValue = $search.val();
const $filtersForm = $('.js-filter-form');
const $input = $(`input[name='${$searchName}']`, $filtersForm);
- const isPristine = Issuable.accessSearchPristine();
+ const isPristine = IssuableIndex.accessSearchPristine();
if (isPristine) {
return;
@@ -92,7 +95,7 @@
$input.val($searchValue);
}
- Issuable.filterResults($filtersForm);
+ IssuableIndex.filterResults($filtersForm);
},
initLabelFilterRemove: function() {
return $(document).off('click', '.js-label-filter-remove').on('click', '.js-label-filter-remove', function(e) {
@@ -103,7 +106,7 @@
return this.value === $button.data('label');
}).remove();
// Submit the form to get new data
- Issuable.filterResults($('.filter-form'));
+ IssuableIndex.filterResults($('.filter-form'));
});
},
filterResults: (function(_this) {
@@ -132,38 +135,18 @@
gl.utils.visitUrl(baseIssuesUrl);
});
},
- initChecks: function() {
- this.issuableBulkActions = $('.bulk-update').data('bulkActions');
- $('.check_all_issues').off('click').on('click', function() {
- $('.selected_issue').prop('checked', this.checked);
- return Issuable.checkChanged();
- });
- return $('.selected_issue').off('change').on('change', Issuable.checkChanged.bind(this));
- },
- checkChanged: function() {
- const $checkedIssues = $('.selected_issue:checked');
- const $updateIssuesIds = $('#update_issuable_ids');
- const $issuesOtherFilters = $('.issues-other-filters');
- const $issuesBulkUpdate = $('.issues_bulk_update');
-
- this.issuableBulkActions.willUpdateLabels = false;
- this.issuableBulkActions.setOriginalDropdownData();
-
- if ($checkedIssues.length > 0) {
- const ids = $.map($checkedIssues, function(value) {
- return $(value).data('id');
+ initBulkUpdate: function(pagePrefix) {
+ const userCanBulkUpdate = $('.issues-bulk-update').length > 0;
+ const alreadyInitialized = !!this.bulkUpdateSidebar;
+
+ if (userCanBulkUpdate && !alreadyInitialized) {
+ IssuableBulkUpdateActions.init({
+ prefixId: pagePrefix,
});
- $updateIssuesIds.val(ids);
- $issuesOtherFilters.hide();
- $issuesBulkUpdate.show();
- } else {
- $updateIssuesIds.val([]);
- $issuesBulkUpdate.hide();
- $issuesOtherFilters.show();
+
+ this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar();
}
- return true;
},
-
resetIncomingEmailToken: function() {
$('.incoming-email-token-reset').on('click', function(e) {
e.preventDefault();
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 694c6177a07..0860e237ce1 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -1,11 +1,11 @@
/* 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 */
-import CreateMergeRequestDropdown from './create_merge_request_dropdown';
+/* global Flash */
-require('./flash');
-require('~/lib/utils/text_utility');
-require('vendor/jquery.waitforimages');
-require('./task_list');
+import 'vendor/jquery.waitforimages';
+import '~/lib/utils/text_utility';
+import './flash';
+import './task_list';
+import CreateMergeRequestDropdown from './create_merge_request_dropdown';
class Issue {
constructor() {
diff --git a/app/assets/javascripts/issue_show/actions/tasks.js b/app/assets/javascripts/issue_show/actions/tasks.js
deleted file mode 100644
index 0740a9f559c..00000000000
--- a/app/assets/javascripts/issue_show/actions/tasks.js
+++ /dev/null
@@ -1,27 +0,0 @@
-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/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
new file mode 100644
index 00000000000..e14414d3f68
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -0,0 +1,273 @@
+<script>
+/* global Flash */
+import Visibility from 'visibilityjs';
+import Poll from '../../lib/utils/poll';
+import eventHub from '../event_hub';
+import Service from '../services/index';
+import Store from '../stores';
+import titleComponent from './title.vue';
+import descriptionComponent from './description.vue';
+import editedComponent from './edited.vue';
+import formComponent from './form.vue';
+import '../../lib/utils/url_utility';
+
+export default {
+ props: {
+ endpoint: {
+ required: true,
+ type: String,
+ },
+ canMove: {
+ required: true,
+ type: Boolean,
+ },
+ canUpdate: {
+ required: true,
+ type: Boolean,
+ },
+ canDestroy: {
+ required: true,
+ type: Boolean,
+ },
+ issuableRef: {
+ type: String,
+ required: true,
+ },
+ initialTitleHtml: {
+ type: String,
+ required: true,
+ },
+ initialTitleText: {
+ type: String,
+ required: true,
+ },
+ initialDescriptionHtml: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialDescriptionText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ issuableTemplates: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isConfidential: {
+ type: Boolean,
+ required: true,
+ },
+ markdownPreviewUrl: {
+ type: String,
+ required: true,
+ },
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ projectsAutocompleteUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ const store = new Store({
+ titleHtml: this.initialTitleHtml,
+ titleText: this.initialTitleText,
+ descriptionHtml: this.initialDescriptionHtml,
+ descriptionText: this.initialDescriptionText,
+ updatedAt: this.updatedAt,
+ updatedByName: this.updatedByName,
+ updatedByPath: this.updatedByPath,
+ });
+
+ return {
+ store,
+ state: store.state,
+ showForm: false,
+ };
+ },
+ computed: {
+ formState() {
+ return this.store.formState;
+ },
+ hasUpdated() {
+ return !!this.state.updatedAt;
+ },
+ },
+ components: {
+ descriptionComponent,
+ titleComponent,
+ editedComponent,
+ formComponent,
+ },
+ methods: {
+ openForm() {
+ if (!this.showForm) {
+ this.showForm = true;
+ this.store.setFormState({
+ title: this.state.titleText,
+ confidential: this.isConfidential,
+ description: this.state.descriptionText,
+ lockedWarningVisible: false,
+ move_to_project_id: 0,
+ updateLoading: false,
+ });
+ }
+ },
+ closeForm() {
+ this.showForm = false;
+ },
+ updateIssuable() {
+ const canPostUpdate = this.store.formState.move_to_project_id !== 0 ?
+ confirm('Are you sure you want to move this issue to another project?') : true; // eslint-disable-line no-alert
+
+ if (!canPostUpdate) {
+ this.store.setFormState({
+ updateLoading: false,
+ });
+ return;
+ }
+
+ this.service.updateIssuable(this.store.formState)
+ .then(res => res.json())
+ .then((data) => {
+ if (location.pathname !== data.web_url) {
+ gl.utils.visitUrl(data.web_url);
+ } else if (data.confidential !== this.isConfidential) {
+ gl.utils.visitUrl(location.pathname);
+ }
+
+ return this.service.getData();
+ })
+ .then(res => res.json())
+ .then((data) => {
+ this.store.updateState(data);
+ eventHub.$emit('close.form');
+ })
+ .catch(() => {
+ eventHub.$emit('close.form');
+ return new Flash('Error updating issue');
+ });
+ },
+ deleteIssuable() {
+ this.service.deleteIssuable()
+ .then(res => res.json())
+ .then((data) => {
+ // Stop the poll so we don't get 404's with the issue not existing
+ this.poll.stop();
+
+ gl.utils.visitUrl(data.web_url);
+ })
+ .catch(() => {
+ eventHub.$emit('close.form');
+ return new Flash('Error deleting issue');
+ });
+ },
+ },
+ created() {
+ this.service = new Service(this.endpoint);
+ this.poll = new Poll({
+ resource: this.service,
+ method: 'getData',
+ successCallback: (res) => {
+ const data = res.json();
+ const shouldUpdate = this.store.stateShouldUpdate(data);
+
+ this.store.updateState(data);
+
+ if (this.showForm && (shouldUpdate.title || shouldUpdate.description)) {
+ this.store.formState.lockedWarningVisible = true;
+ }
+ },
+ errorCallback(err) {
+ throw new Error(err);
+ },
+ });
+
+ if (!Visibility.hidden()) {
+ this.poll.makeRequest();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
+
+ eventHub.$on('delete.issuable', this.deleteIssuable);
+ eventHub.$on('update.issuable', this.updateIssuable);
+ eventHub.$on('close.form', this.closeForm);
+ eventHub.$on('open.form', this.openForm);
+ },
+ beforeDestroy() {
+ eventHub.$off('delete.issuable', this.deleteIssuable);
+ eventHub.$off('update.issuable', this.updateIssuable);
+ eventHub.$off('close.form', this.closeForm);
+ eventHub.$off('open.form', this.openForm);
+ },
+};
+</script>
+
+<template>
+ <div>
+ <form-component
+ v-if="canUpdate && showForm"
+ :form-state="formState"
+ :can-move="canMove"
+ :can-destroy="canDestroy"
+ :issuable-templates="issuableTemplates"
+ :markdown-docs="markdownDocs"
+ :markdown-preview-url="markdownPreviewUrl"
+ :project-path="projectPath"
+ :project-namespace="projectNamespace"
+ :projects-autocomplete-url="projectsAutocompleteUrl"
+ />
+ <div v-else>
+ <title-component
+ :issuable-ref="issuableRef"
+ :title-html="state.titleHtml"
+ :title-text="state.titleText" />
+ <description-component
+ v-if="state.descriptionHtml"
+ :can-update="canUpdate"
+ :description-html="state.descriptionHtml"
+ :description-text="state.descriptionText"
+ :updated-at="state.updatedAt"
+ :task-status="state.taskStatus" />
+ <edited-component
+ v-if="hasUpdated"
+ :updated-at="state.updatedAt"
+ :updated-by-name="state.updatedByName"
+ :updated-by-path="state.updatedByPath" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue
new file mode 100644
index 00000000000..5ae617356e0
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -0,0 +1,96 @@
+<script>
+ import animateMixin from '../mixins/animate';
+
+ export default {
+ mixins: [animateMixin],
+ props: {
+ canUpdate: {
+ type: Boolean,
+ required: true,
+ },
+ descriptionHtml: {
+ type: String,
+ required: true,
+ },
+ descriptionText: {
+ type: String,
+ required: true,
+ },
+ taskStatus: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ preAnimation: false,
+ pulseAnimation: false,
+ };
+ },
+ watch: {
+ descriptionHtml() {
+ this.animateChange();
+
+ this.$nextTick(() => {
+ this.renderGFM();
+ });
+ },
+ taskStatus() {
+ const taskRegexMatches = this.taskStatus.match(/(\d+) of (\d+)/);
+ const $issuableHeader = $('.issuable-meta');
+ const $tasks = $('#task_status', $issuableHeader);
+ const $tasksShort = $('#task_status_short', $issuableHeader);
+
+ if (taskRegexMatches) {
+ $tasks.text(this.taskStatus);
+ $tasksShort.text(`${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ? 's' : ''}`);
+ } else {
+ $tasks.text('');
+ $tasksShort.text('');
+ }
+ },
+ },
+ methods: {
+ renderGFM() {
+ $(this.$refs['gfm-entry-content']).renderGFM();
+
+ if (this.canUpdate) {
+ // eslint-disable-next-line no-new
+ new gl.TaskList({
+ dataType: 'issue',
+ fieldName: 'description',
+ selector: '.detail-page-description',
+ });
+ }
+ },
+ },
+ mounted() {
+ this.renderGFM();
+ },
+ };
+</script>
+
+<template>
+ <div
+ v-if="descriptionHtml"
+ class="description"
+ :class="{
+ 'js-task-list-container': canUpdate
+ }">
+ <div
+ class="wiki"
+ :class="{
+ 'issue-realtime-pre-pulse': preAnimation,
+ 'issue-realtime-trigger-pulse': pulseAnimation
+ }"
+ v-html="descriptionHtml"
+ ref="gfm-content">
+ </div>
+ <textarea
+ class="hidden js-task-list-field"
+ v-if="descriptionText"
+ v-model="descriptionText">
+ </textarea>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue
new file mode 100644
index 00000000000..8c81575fe6f
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/edit_actions.vue
@@ -0,0 +1,79 @@
+<script>
+ import updateMixin from '../mixins/update';
+ import eventHub from '../event_hub';
+
+ export default {
+ mixins: [updateMixin],
+ props: {
+ canDestroy: {
+ type: Boolean,
+ required: true,
+ },
+ formState: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ deleteLoading: false,
+ };
+ },
+ computed: {
+ isSubmitEnabled() {
+ return this.formState.title.trim() !== '';
+ },
+ },
+ methods: {
+ closeForm() {
+ eventHub.$emit('close.form');
+ },
+ deleteIssuable() {
+ // eslint-disable-next-line no-alert
+ if (confirm('Issue will be removed! Are you sure?')) {
+ this.deleteLoading = true;
+
+ eventHub.$emit('delete.issuable');
+ }
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="prepend-top-default append-bottom-default clearfix">
+ <button
+ class="btn btn-save pull-left"
+ :class="{ disabled: formState.updateLoading || !isSubmitEnabled }"
+ type="submit"
+ :disabled="formState.updateLoading || !isSubmitEnabled"
+ @click.prevent="updateIssuable">
+ Save changes
+ <i
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true"
+ v-if="formState.updateLoading">
+ </i>
+ </button>
+ <button
+ class="btn btn-default pull-right"
+ type="button"
+ @click="closeForm">
+ Cancel
+ </button>
+ <button
+ v-if="canDestroy"
+ class="btn btn-danger pull-right append-right-default"
+ :class="{ disabled: deleteLoading }"
+ type="button"
+ :disabled="deleteLoading"
+ @click="deleteIssuable">
+ Delete
+ <i
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true"
+ v-if="deleteLoading">
+ </i>
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issue_show/components/edited.vue
index f5038e55c09..d59e6d11032 100644
--- a/app/assets/javascripts/issue_show/components/edited.vue
+++ b/app/assets/javascripts/issue_show/components/edited.vue
@@ -53,3 +53,4 @@ export default {
</span>
</small>
</template>
+
diff --git a/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue b/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue
new file mode 100644
index 00000000000..a0ff08e9111
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue
@@ -0,0 +1,23 @@
+<script>
+ export default {
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <fieldset class="checkbox">
+ <label for="issue-confidential">
+ <input
+ type="checkbox"
+ value="1"
+ id="issue-confidential"
+ v-model="formState.confidential" />
+ This issue is confidential and should only be visible to team members with at least Reporter access.
+ </label>
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue
new file mode 100644
index 00000000000..30a1be5cb50
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/description.vue
@@ -0,0 +1,54 @@
+<script>
+ /* global Flash */
+ import updateMixin from '../../mixins/update';
+ import markdownField from '../../../vue_shared/components/markdown/field.vue';
+
+ export default {
+ mixins: [updateMixin],
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ markdownPreviewUrl: {
+ type: String,
+ required: true,
+ },
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ },
+ components: {
+ markdownField,
+ },
+ mounted() {
+ this.$refs.textarea.focus();
+ },
+ };
+</script>
+
+<template>
+ <div class="common-note-form">
+ <label
+ class="sr-only"
+ for="issue-description">
+ Description
+ </label>
+ <markdown-field
+ :markdown-preview-url="markdownPreviewUrl"
+ :markdown-docs="markdownDocs">
+ <textarea
+ id="issue-description"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ data-supports-slash-commands="false"
+ aria-label="Description"
+ v-model="formState.description"
+ ref="textarea"
+ slot="textarea"
+ placeholder="Write a comment or drag your files here..."
+ @keydown.meta.enter="updateIssuable">
+ </textarea>
+ </markdown-field>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue
new file mode 100644
index 00000000000..1c40b286513
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue
@@ -0,0 +1,111 @@
+<script>
+ export default {
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ issuableTemplates: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ issuableTemplatesJson() {
+ return JSON.stringify(this.issuableTemplates);
+ },
+ },
+ mounted() {
+ // Create the editor for the template
+ const editor = document.querySelector('.detail-page-description .note-textarea') || {};
+ editor.setValue = (val) => {
+ this.formState.description = val;
+ };
+ editor.getValue = () => this.formState.description;
+
+ this.issuableTemplate = new gl.IssuableTemplateSelectors({
+ $dropdowns: $(this.$refs.toggle),
+ editor,
+ });
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="dropdown js-issuable-selector-wrap"
+ data-issuable-type="issue">
+ <button
+ class="dropdown-menu-toggle js-issuable-selector"
+ type="button"
+ ref="toggle"
+ data-field-name="issuable_template"
+ data-selected="null"
+ data-toggle="dropdown"
+ :data-namespace-path="projectNamespace"
+ :data-project-path="projectPath"
+ :data-data="issuableTemplatesJson">
+ <span class="dropdown-toggle-text">
+ Choose a template
+ </span>
+ <i
+ aria-hidden="true"
+ class="fa fa-chevron-down">
+ </i>
+ </button>
+ <div class="dropdown-menu dropdown-select">
+ <div class="dropdown-title">
+ Choose a template
+ <button
+ class="dropdown-title-button dropdown-menu-close"
+ aria-label="Close"
+ type="button">
+ <i
+ aria-hidden="true"
+ class="fa fa-times dropdown-menu-close-icon">
+ </i>
+ </button>
+ </div>
+ <div class="dropdown-input">
+ <input
+ type="search"
+ class="dropdown-input-field"
+ placeholder="Filter"
+ autocomplete="off" />
+ <i
+ aria-hidden="true"
+ class="fa fa-search dropdown-input-search">
+ </i>
+ <i
+ role="button"
+ aria-label="Clear templates search input"
+ class="fa fa-times dropdown-input-clear js-dropdown-input-clear">
+ </i>
+ </div>
+ <div class="dropdown-content"></div>
+ <div class="dropdown-footer">
+ <ul class="dropdown-footer-list">
+ <li>
+ <a class="no-template">
+ No template
+ </a>
+ </li>
+ <li>
+ <a class="reset-template">
+ Reset template
+ </a>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/project_move.vue b/app/assets/javascripts/issue_show/components/fields/project_move.vue
new file mode 100644
index 00000000000..f811fb0de24
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/project_move.vue
@@ -0,0 +1,83 @@
+<script>
+ import tooltipMixin from '../../../vue_shared/mixins/tooltip';
+
+ export default {
+ mixins: [
+ tooltipMixin,
+ ],
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ projectsAutocompleteUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ mounted() {
+ const $moveDropdown = $(this.$refs['move-dropdown']);
+
+ $moveDropdown.select2({
+ ajax: {
+ url: this.projectsAutocompleteUrl,
+ quietMillis: 125,
+ data(term, page, context) {
+ return {
+ search: term,
+ offset_id: context,
+ };
+ },
+ results(data) {
+ const more = data.length >= 50;
+ const context = data[data.length - 1] ? data[data.length - 1].id : null;
+
+ return {
+ results: data,
+ more,
+ context,
+ };
+ },
+ },
+ formatResult(project) {
+ return project.name_with_namespace;
+ },
+ formatSelection(project) {
+ return project.name_with_namespace;
+ },
+ })
+ .on('change', (e) => {
+ this.formState.move_to_project_id = parseInt(e.target.value, 10);
+ });
+ },
+ beforeDestroy() {
+ $(this.$refs['move-dropdown']).select2('destroy');
+ },
+ };
+</script>
+
+<template>
+ <fieldset>
+ <label
+ for="issuable-move"
+ class="sr-only">
+ Move
+ </label>
+ <div class="issuable-form-select-holder append-right-5">
+ <input
+ ref="move-dropdown"
+ type="hidden"
+ id="issuable-move"
+ data-placeholder="Move to a different project" />
+ </div>
+ <span
+ data-placement="auto top"
+ title="Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location."
+ ref="tooltip">
+ <i
+ class="fa fa-question-circle"
+ aria-hidden="true">
+ </i>
+ </span>
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issue_show/components/fields/title.vue
new file mode 100644
index 00000000000..6556bf117e2
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/title.vue
@@ -0,0 +1,31 @@
+<script>
+ import updateMixin from '../../mixins/update';
+
+ export default {
+ mixins: [updateMixin],
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <fieldset>
+ <label
+ class="sr-only"
+ for="issue-title">
+ Title
+ </label>
+ <input
+ id="issue-title"
+ class="form-control"
+ type="text"
+ placeholder="Issue title"
+ aria-label="Issue title"
+ v-model="formState.title"
+ @keydown.meta.enter="updateIssuable" />
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue
new file mode 100644
index 00000000000..76ec3dc9a5d
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -0,0 +1,104 @@
+<script>
+ import lockedWarning from './locked_warning.vue';
+ import titleField from './fields/title.vue';
+ import descriptionField from './fields/description.vue';
+ import editActions from './edit_actions.vue';
+ import descriptionTemplate from './fields/description_template.vue';
+ import projectMove from './fields/project_move.vue';
+ import confidentialCheckbox from './fields/confidential_checkbox.vue';
+
+ export default {
+ props: {
+ canMove: {
+ type: Boolean,
+ required: true,
+ },
+ canDestroy: {
+ type: Boolean,
+ required: true,
+ },
+ formState: {
+ type: Object,
+ required: true,
+ },
+ issuableTemplates: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ markdownPreviewUrl: {
+ type: String,
+ required: true,
+ },
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ projectsAutocompleteUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ components: {
+ lockedWarning,
+ titleField,
+ descriptionField,
+ descriptionTemplate,
+ editActions,
+ projectMove,
+ confidentialCheckbox,
+ },
+ computed: {
+ hasIssuableTemplates() {
+ return this.issuableTemplates.length;
+ },
+ },
+ };
+</script>
+
+<template>
+ <form>
+ <locked-warning v-if="formState.lockedWarningVisible" />
+ <div class="row">
+ <div
+ class="col-sm-4 col-lg-3"
+ v-if="hasIssuableTemplates">
+ <description-template
+ :form-state="formState"
+ :issuable-templates="issuableTemplates"
+ :project-path="projectPath"
+ :project-namespace="projectNamespace" />
+ </div>
+ <div
+ :class="{
+ 'col-sm-8 col-lg-9': hasIssuableTemplates,
+ 'col-xs-12': !hasIssuableTemplates,
+ }">
+ <title-field
+ :form-state="formState"
+ :issuable-templates="issuableTemplates" />
+ </div>
+ </div>
+ <description-field
+ :form-state="formState"
+ :markdown-preview-url="markdownPreviewUrl"
+ :markdown-docs="markdownDocs" />
+ <confidential-checkbox
+ :form-state="formState" />
+ <project-move
+ v-if="canMove"
+ :form-state="formState"
+ :projects-autocomplete-url="projectsAutocompleteUrl" />
+ <edit-actions
+ :form-state="formState"
+ :can-destroy="canDestroy" />
+ </form>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issue_show/components/locked_warning.vue
new file mode 100644
index 00000000000..1c2789f154a
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/locked_warning.vue
@@ -0,0 +1,20 @@
+<script>
+ export default {
+ computed: {
+ currentPath() {
+ return location.pathname;
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="alert alert-danger">
+ Someone edited the issue at the same time you did. Please check out
+ <a
+ :href="currentPath"
+ target="_blank"
+ rel="nofollow">the issue</a>
+ and make sure your changes will not unintentionally remove theirs.
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue
new file mode 100644
index 00000000000..a9dabd4cff1
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/title.vue
@@ -0,0 +1,53 @@
+<script>
+ import animateMixin from '../mixins/animate';
+
+ export default {
+ mixins: [animateMixin],
+ data() {
+ return {
+ preAnimation: false,
+ pulseAnimation: false,
+ titleEl: document.querySelector('title'),
+ };
+ },
+ props: {
+ issuableRef: {
+ type: String,
+ required: true,
+ },
+ titleHtml: {
+ type: String,
+ required: true,
+ },
+ titleText: {
+ type: String,
+ required: true,
+ },
+ },
+ watch: {
+ titleHtml() {
+ this.setPageTitle();
+ this.animateChange();
+ },
+ },
+ methods: {
+ setPageTitle() {
+ const currentPageTitleScope = this.titleEl.innerText.split('·');
+ currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `;
+ this.titleEl.textContent = currentPageTitleScope.join('·');
+ },
+ },
+ };
+</script>
+
+<template>
+ <h2
+ class="title"
+ :class="{
+ 'issue-realtime-pre-pulse': preAnimation,
+ 'issue-realtime-trigger-pulse': pulseAnimation
+ }"
+ v-html="titleHtml"
+ >
+ </h2>
+</template>
diff --git a/app/assets/javascripts/issue_show/event_hub.js b/app/assets/javascripts/issue_show/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/issue_show/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index 3401fc8d83b..14b2a1e18e9 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -1,25 +1,52 @@
import Vue from 'vue';
-import IssueTitle from './issue_title_description.vue';
+import eventHub from './event_hub';
+import issuableApp from './components/app.vue';
import '../vue_shared/vue_resource_interceptor';
-(() => {
- const issueTitleData = document.querySelector('.issue-title-data').dataset;
- const initialTitle = document.querySelector('.js-issue-title').innerHTML;
- const initialDescription = document.querySelector('.js-issue-description');
- const { canUpdateTasksClass, endpoint, isEdited } = issueTitleData;
+document.addEventListener('DOMContentLoaded', () => {
+ const initialDataEl = document.getElementById('js-issuable-app-initial-data');
+ const initialData = JSON.parse(initialDataEl.innerHTML.replace(/&quot;/g, '"'));
- const vm = new Vue({
- el: '.issue-title-entrypoint',
- render: createElement => createElement(IssueTitle, {
- props: {
- canUpdateTasksClass,
- endpoint,
- isEdited,
- initialTitle,
- initialDescription: initialDescription ? initialDescription.innerHTML : '',
- },
- }),
+ $('.issuable-edit').on('click', (e) => {
+ e.preventDefault();
+
+ eventHub.$emit('open.form');
});
- return vm;
-})();
+ return new Vue({
+ el: document.getElementById('js-issuable-app'),
+ components: {
+ issuableApp,
+ },
+ data() {
+ return {
+ ...initialData,
+ };
+ },
+ render(createElement) {
+ return createElement('issuable-app', {
+ props: {
+ canUpdate: this.canUpdate,
+ canDestroy: this.canDestroy,
+ canMove: this.canMove,
+ endpoint: this.endpoint,
+ issuableRef: this.issuableRef,
+ initialTitleHtml: this.initialTitleHtml,
+ initialTitleText: this.initialTitleText,
+ initialDescriptionHtml: this.initialDescriptionHtml,
+ initialDescriptionText: this.initialDescriptionText,
+ issuableTemplates: this.issuableTemplates,
+ isConfidential: this.isConfidential,
+ markdownPreviewUrl: this.markdownPreviewUrl,
+ markdownDocs: this.markdownDocs,
+ projectPath: this.projectPath,
+ projectNamespace: this.projectNamespace,
+ projectsAutocompleteUrl: this.projectsAutocompleteUrl,
+ updatedAt: this.updatedAt,
+ updatedByName: this.updatedByName,
+ updatedByPath: this.updatedByPath,
+ },
+ });
+ },
+ });
+});
diff --git a/app/assets/javascripts/issue_show/mixins/animate.js b/app/assets/javascripts/issue_show/mixins/animate.js
new file mode 100644
index 00000000000..4816393da1f
--- /dev/null
+++ b/app/assets/javascripts/issue_show/mixins/animate.js
@@ -0,0 +1,13 @@
+export default {
+ methods: {
+ animateChange() {
+ this.preAnimation = true;
+ this.pulseAnimation = false;
+
+ setTimeout(() => {
+ this.preAnimation = false;
+ this.pulseAnimation = true;
+ });
+ },
+ },
+};
diff --git a/app/assets/javascripts/issue_show/mixins/update.js b/app/assets/javascripts/issue_show/mixins/update.js
new file mode 100644
index 00000000000..72be65b426f
--- /dev/null
+++ b/app/assets/javascripts/issue_show/mixins/update.js
@@ -0,0 +1,10 @@
+import eventHub from '../event_hub';
+
+export default {
+ methods: {
+ updateIssuable() {
+ this.formState.updateLoading = true;
+ eventHub.$emit('update.issuable');
+ },
+ },
+};
diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js
index c4ab0b1e07a..6f0fd0b1768 100644
--- a/app/assets/javascripts/issue_show/services/index.js
+++ b/app/assets/javascripts/issue_show/services/index.js
@@ -1,10 +1,29 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
export default class Service {
- constructor(resource, endpoint) {
- this.resource = resource;
+ constructor(endpoint) {
this.endpoint = endpoint;
+
+ this.resource = Vue.resource(`${this.endpoint}.json`, {}, {
+ realtimeChanges: {
+ method: 'GET',
+ url: `${this.endpoint}/realtime_changes`,
+ },
+ });
+ }
+
+ getData() {
+ return this.resource.realtimeChanges();
+ }
+
+ deleteIssuable() {
+ return this.resource.delete();
}
- getTitle() {
- return this.resource.get(this.endpoint);
+ updateIssuable(data) {
+ return this.resource.update(data);
}
}
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js
new file mode 100644
index 00000000000..27c2d349f52
--- /dev/null
+++ b/app/assets/javascripts/issue_show/stores/index.js
@@ -0,0 +1,52 @@
+export default class Store {
+ constructor({
+ titleHtml,
+ titleText,
+ descriptionHtml,
+ descriptionText,
+ updatedAt,
+ updatedByName,
+ updatedByPath,
+ }) {
+ this.state = {
+ titleHtml,
+ titleText,
+ descriptionHtml,
+ descriptionText,
+ taskStatus: '',
+ updatedAt,
+ updatedByName,
+ updatedByPath,
+ };
+ this.formState = {
+ title: '',
+ confidential: false,
+ description: '',
+ lockedWarningVisible: false,
+ move_to_project_id: 0,
+ updateLoading: false,
+ };
+ }
+
+ updateState(data) {
+ this.state.titleHtml = data.title;
+ this.state.titleText = data.title_text;
+ this.state.descriptionHtml = data.description;
+ this.state.descriptionText = data.description_text;
+ this.state.taskStatus = data.task_status;
+ this.state.updatedAt = data.updated_at;
+ this.state.updatedByName = data.updated_by_name;
+ this.state.updatedByPath = data.updated_by_path;
+ }
+
+ stateShouldUpdate(data) {
+ return {
+ title: this.state.titleText !== data.title_text,
+ description: this.state.descriptionText !== data.description_text,
+ };
+ }
+
+ setFormState(state) {
+ this.formState = Object.assign(this.formState, state);
+ }
+}
diff --git a/app/assets/javascripts/issues_bulk_assignment.js b/app/assets/javascripts/issues_bulk_assignment.js
deleted file mode 100644
index fee3429e2b8..00000000000
--- a/app/assets/javascripts/issues_bulk_assignment.js
+++ /dev/null
@@ -1,166 +0,0 @@
-/* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, space-before-function-paren, prefer-arrow-callback, max-len, no-unused-expressions, no-sequences, no-underscore-dangle, no-unused-vars, no-param-reassign */
-/* global Issuable */
-/* global Flash */
-
-((global) => {
- class IssuableBulkActions {
- constructor({ container, form, issues, prefixId } = {}) {
- this.prefixId = prefixId || 'issue_';
- this.form = form || this.getElement('.bulk-update');
- this.$labelDropdown = this.form.find('.js-label-select');
- this.issues = issues || this.getElement('.issues-list .issue');
- this.form.data('bulkActions', this);
- this.willUpdateLabels = false;
- this.bindEvents();
- // Fixes bulk-assign not working when navigating through pages
- Issuable.initChecks();
- }
-
- bindEvents() {
- return this.form.off('submit').on('submit', this.onFormSubmit.bind(this));
- }
-
- onFormSubmit(e) {
- e.preventDefault();
- return this.submit();
- }
-
- submit() {
- const _this = this;
- const xhr = $.ajax({
- url: this.form.attr('action'),
- method: this.form.attr('method'),
- dataType: 'JSON',
- data: this.getFormDataAsObject()
- });
- xhr.done(() => window.location.reload());
- xhr.fail(() => new Flash("Issue update failed"));
- return xhr.always(this.onFormSubmitAlways.bind(this));
- }
-
- onFormSubmitAlways() {
- return this.form.find('[type="submit"]').enable();
- }
-
- getSelectedIssues() {
- return this.issues.has('.selected_issue:checked');
- }
-
- getLabelsFromSelection() {
- const labels = [];
- this.getSelectedIssues().map(function() {
- const labelsData = $(this).data('labels');
- if (labelsData) {
- return labelsData.map(function(labelId) {
- if (labels.indexOf(labelId) === -1) {
- return labels.push(labelId);
- }
- });
- }
- });
- return labels;
- }
-
- /**
- * Will return only labels that were marked previously and the user has unmarked
- * @return {Array} Label IDs
- */
-
- getUnmarkedIndeterminedLabels() {
- const result = [];
- const labelsToKeep = this.$labelDropdown.data('indeterminate');
-
- this.getLabelsFromSelection().forEach((id) => {
- if (labelsToKeep.indexOf(id) === -1) {
- result.push(id);
- }
- });
-
- return result;
- }
-
- /**
- * Simple form serialization, it will return just what we need
- * Returns key/value pairs from form data
- */
-
- getFormDataAsObject() {
- 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(),
- add_label_ids: [],
- remove_label_ids: []
- }
- };
- if (this.willUpdateLabels) {
- formData.update.add_label_ids = this.$labelDropdown.data('marked');
- formData.update.remove_label_ids = this.$labelDropdown.data('unmarked');
- }
- return formData;
- }
-
- setOriginalDropdownData() {
- const $labelSelect = $('.bulk-update .js-label-select');
- $labelSelect.data('common', this.getOriginalCommonIds());
- $labelSelect.data('marked', this.getOriginalMarkedIds());
- $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds());
- }
-
- // From issuable's initial bulk selection
- getOriginalCommonIds() {
- const labelIds = [];
-
- this.getElement('.selected_issue:checked').each((i, el) => {
- labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
- });
- return _.intersection.apply(this, labelIds);
- }
-
- // From issuable's initial bulk selection
- getOriginalMarkedIds() {
- const labelIds = [];
- this.getElement('.selected_issue:checked').each((i, el) => {
- labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
- });
- return _.intersection.apply(this, labelIds);
- }
-
- // From issuable's initial bulk selection
- getOriginalIndeterminateIds() {
- const uniqueIds = [];
- const labelIds = [];
- let issuableLabels = [];
-
- // Collect unique label IDs for all checked issues
- this.getElement('.selected_issue:checked').each((i, el) => {
- issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels');
- issuableLabels.forEach((labelId) => {
- // Store unique IDs
- if (uniqueIds.indexOf(labelId) === -1) {
- uniqueIds.push(labelId);
- }
- });
- // Store array of IDs per issuable
- labelIds.push(issuableLabels);
- });
- // Add uniqueIds to add it as argument for _.intersection
- labelIds.unshift(uniqueIds);
- // Return IDs that are present but not in all selected issueables
- return _.difference(uniqueIds, _.intersection.apply(this, labelIds));
- }
-
- getElement(selector) {
- this.scopeEl = this.scopeEl || $('.content');
- return this.scopeEl.find(selector);
- }
- }
-
- global.IssuableBulkActions = IssuableBulkActions;
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/labels.js b/app/assets/javascripts/labels.js
index 17a3fc1b1e4..03dd61b4263 100644
--- a/app/assets/javascripts/labels.js
+++ b/app/assets/javascripts/labels.js
@@ -1,11 +1,9 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, vars-on-top, no-unused-vars, max-len */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.Labels = (function() {
function Labels() {
- this.setSuggestedColor = bind(this.setSuggestedColor, this);
- this.updateColorPreview = bind(this.updateColorPreview, this);
+ this.setSuggestedColor = this.setSuggestedColor.bind(this);
+ this.updateColorPreview = this.updateColorPreview.bind(this);
var form;
form = $('.label-form');
this.cleanBinding();
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index ac5ce84e31b..8d7d3d73571 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -2,6 +2,8 @@
/* global Issuable */
/* global ListLabel */
+import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
+
(function() {
this.LabelsSelect = (function() {
function LabelsSelect(els) {
@@ -430,20 +432,15 @@
if ($('.selected_issue:checked').length) {
return;
}
- return $('.issues_bulk_update .labels-filter .dropdown-toggle-text').text('Label');
+ return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text('Label');
};
LabelsSelect.prototype.enableBulkLabelDropdown = function() {
- var issuableBulkActions;
- if ($('.selected_issue:checked').length) {
- issuableBulkActions = $('.bulk-update').data('bulkActions');
- return issuableBulkActions.willUpdateLabels = true;
- }
+ IssuableBulkUpdateActions.willUpdateLabels = true;
};
LabelsSelect.prototype.setDropdownData = function($dropdown, isMarking, value) {
var i, markedIds, unmarkedIds, indeterminateIds;
- var issuableBulkActions = $('.bulk-update').data('bulkActions');
markedIds = $dropdown.data('marked') || [];
unmarkedIds = $dropdown.data('unmarked') || [];
@@ -469,13 +466,13 @@
}
// If an indeterminate item is being unmarked
- if (issuableBulkActions.getOriginalIndeterminateIds().indexOf(value) > -1) {
+ if (IssuableBulkUpdateActions.getOriginalIndeterminateIds().indexOf(value) > -1) {
unmarkedIds.push(value);
}
// If a marked item is being unmarked
// (a marked item could also be a label that is present in all selection)
- if (issuableBulkActions.getOriginalCommonIds().indexOf(value) > -1) {
+ if (IssuableBulkUpdateActions.getOriginalCommonIds().indexOf(value) > -1) {
unmarkedIds.push(value);
}
}
diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js
index a5f99bcdd8f..71064ccc539 100644
--- a/app/assets/javascripts/layout_nav.js
+++ b/app/assets/javascripts/layout_nav.js
@@ -1,4 +1,5 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, no-unused-vars, one-var, one-var-declaration-per-line, vars-on-top, max-len */
+import _ from 'underscore';
(function() {
var hideEndFade;
@@ -45,4 +46,13 @@
}
});
});
+
+ function applyScrollNavClass() {
+ const scrollOpacityHeight = 40;
+ $('.navbar-border').css('opacity', Math.min($(window).scrollTop() / scrollOpacityHeight, 1));
+ }
+
+ $(() => {
+ $(window).on('scroll', _.throttle(applyScrollNavClass, 100));
+ });
}).call(window);
diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js
index d99eefb5089..7477b5a5214 100644
--- a/app/assets/javascripts/lib/utils/ajax_cache.js
+++ b/app/assets/javascripts/lib/utils/ajax_cache.js
@@ -1,32 +1,44 @@
-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));
+import Cache from './cache';
+
+class AjaxCache extends Cache {
+ constructor() {
+ super();
+ this.pendingRequests = { };
+ }
+
+ retrieve(endpoint, forceRetrieve) {
+ if (this.hasData(endpoint) && !forceRetrieve) {
+ return Promise.resolve(this.get(endpoint));
+ }
+
+ let pendingRequest = this.pendingRequests[endpoint];
+
+ if (!pendingRequest) {
+ pendingRequest = new Promise((resolve, reject) => {
+ // jQuery 2 is not Promises/A+ compatible (missing catch)
+ $.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;
+ delete this.pendingRequests[endpoint];
+ })
+ .catch((error) => {
+ delete this.pendingRequests[endpoint];
+ throw error;
+ });
+
+ this.pendingRequests[endpoint] = pendingRequest;
}
- 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));
- },
-};
+ return pendingRequest.then(() => this.get(endpoint));
+ }
+}
-export default AjaxCache;
+export default new AjaxCache();
diff --git a/app/assets/javascripts/lib/utils/cache.js b/app/assets/javascripts/lib/utils/cache.js
new file mode 100644
index 00000000000..3141f1eeafc
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/cache.js
@@ -0,0 +1,19 @@
+class Cache {
+ constructor() {
+ this.internalStorage = { };
+ }
+
+ get(key) {
+ return this.internalStorage[key];
+ }
+
+ hasData(key) {
+ return Object.prototype.hasOwnProperty.call(this.internalStorage, key);
+ }
+
+ remove(key) {
+ delete this.internalStorage[key];
+ }
+}
+
+export default Cache;
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 2f682fbd2fb..a537267643e 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -135,7 +135,10 @@
gl.utils.getUrlParamsArray = function () {
// We can trust that each param has one & since values containing & will be encoded
// Remove the first character of search as it is always ?
- return window.location.search.slice(1).split('&');
+ return window.location.search.slice(1).split('&').map((param) => {
+ const split = param.split('=');
+ return [decodeURI(split[0]), split[1]].join('=');
+ });
};
gl.utils.isMetaKey = function(e) {
@@ -195,10 +198,12 @@
const textBefore = value.substring(0, selectionStart);
const textAfter = value.substring(selectionEnd, value.length);
- const newText = textBefore + text + textAfter;
+
+ const insertedText = text instanceof Function ? text(textBefore, textAfter) : text;
+ const newText = textBefore + insertedText + textAfter;
target.value = newText;
- target.selectionStart = target.selectionEnd = selectionStart + text.length;
+ target.selectionStart = target.selectionEnd = selectionStart + insertedText.length;
// Trigger autosave
$(target).trigger('input');
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 82dcbdc26c8..40eadd9396c 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -1,9 +1,15 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, comma-dangle, no-unused-expressions, prefer-template, max-len */
-/* global timeago */
-/* global dateFormat */
-window.timeago = require('timeago.js');
-window.dateFormat = require('vendor/date.format');
+import timeago from 'timeago.js';
+import dateFormat from 'vendor/date.format';
+
+import {
+ lang,
+ s__,
+} from '../../locale';
+
+window.timeago = timeago;
+window.dateFormat = dateFormat;
(function() {
(function(w) {
@@ -47,26 +53,45 @@ window.dateFormat = require('vendor/date.format');
var locale;
if (!timeagoInstance) {
+ const localeRemaining = function(number, index) {
+ return [
+ [s__('Timeago|less than a minute ago'), s__('Timeago|a while')],
+ [s__('Timeago|less than a minute ago'), s__('Timeago|%s seconds remaining')],
+ [s__('Timeago|about a minute ago'), s__('Timeago|1 minute remaining')],
+ [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')],
+ [s__('Timeago|about an hour ago'), s__('Timeago|1 hour remaining')],
+ [s__('Timeago|about %s hours ago'), s__('Timeago|%s hours remaining')],
+ [s__('Timeago|a day ago'), s__('Timeago|1 day remaining')],
+ [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')],
+ [s__('Timeago|a week ago'), s__('Timeago|1 week remaining')],
+ [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')],
+ [s__('Timeago|a month ago'), s__('Timeago|1 month remaining')],
+ [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')],
+ [s__('Timeago|a year ago'), s__('Timeago|1 year remaining')],
+ [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')]
+ ][index];
+ };
locale = function(number, index) {
return [
- ['less than a minute ago', 'a while'],
- ['less than a minute ago', 'in %s seconds'],
- ['about a minute ago', 'in 1 minute'],
- ['%s minutes ago', 'in %s minutes'],
- ['about an hour ago', 'in 1 hour'],
- ['about %s hours ago', 'in %s hours'],
- ['a day ago', 'in 1 day'],
- ['%s days ago', 'in %s days'],
- ['a week ago', 'in 1 week'],
- ['%s weeks ago', 'in %s weeks'],
- ['a month ago', 'in 1 month'],
- ['%s months ago', 'in %s months'],
- ['a year ago', 'in 1 year'],
- ['%s years ago', 'in %s years']
+ [s__('Timeago|less than a minute ago'), s__('Timeago|a while')],
+ [s__('Timeago|less than a minute ago'), s__('Timeago|in %s seconds')],
+ [s__('Timeago|about a minute ago'), s__('Timeago|in 1 minute')],
+ [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')],
+ [s__('Timeago|about an hour ago'), s__('Timeago|in 1 hour')],
+ [s__('Timeago|about %s hours ago'), s__('Timeago|in %s hours')],
+ [s__('Timeago|a day ago'), s__('Timeago|in 1 day')],
+ [s__('Timeago|%s days ago'), s__('Timeago|in %s days')],
+ [s__('Timeago|a week ago'), s__('Timeago|in 1 week')],
+ [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')],
+ [s__('Timeago|a month ago'), s__('Timeago|in 1 month')],
+ [s__('Timeago|%s months ago'), s__('Timeago|in %s months')],
+ [s__('Timeago|a year ago'), s__('Timeago|in 1 year')],
+ [s__('Timeago|%s years ago'), s__('Timeago|in %s years')]
][index];
};
- timeago.register('gl_en', locale);
+ timeago.register(lang, locale);
+ timeago.register(`${lang}-remaining`, localeRemaining);
timeagoInstance = timeago();
}
@@ -78,13 +103,11 @@ window.dateFormat = require('vendor/date.format');
if (!time) {
return '';
}
- suffix || (suffix = 'remaining');
- expiredLabel || (expiredLabel = 'Past due');
- timefor = gl.utils.getTimeago().format(time).replace('in', '');
- if (timefor.indexOf('ago') > -1) {
+ if (new Date(time) < new Date()) {
+ expiredLabel || (expiredLabel = s__('Timeago|Past due'));
timefor = expiredLabel;
} else {
- timefor = timefor.trim() + ' ' + suffix;
+ timefor = gl.utils.getTimeago().format(time, `${lang}-remaining`).trim();
}
return timefor;
};
@@ -101,8 +124,7 @@ window.dateFormat = require('vendor/date.format');
};
w.gl.utils.updateTimeagoText = function(el) {
- const timeago = gl.utils.getTimeago();
- const formattedDate = timeago.format(el.getAttribute('datetime'), 'gl_en');
+ const formattedDate = gl.utils.getTimeago().format(el.getAttribute('datetime'), lang);
if (el.textContent !== formattedDate) {
el.textContent = formattedDate;
diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js
index bc109a69c20..415e50f32ae 100644
--- a/app/assets/javascripts/lib/utils/http_status.js
+++ b/app/assets/javascripts/lib/utils/http_status.js
@@ -2,9 +2,7 @@
* exports HTTP status codes
*/
-const statusCodes = {
+export default {
NO_CONTENT: 204,
OK: 200,
};
-
-module.exports = statusCodes;
diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js
index 66f39122a66..973d6119158 100644
--- a/app/assets/javascripts/lib/utils/notify.js
+++ b/app/assets/javascripts/lib/utils/notify.js
@@ -1,47 +1,48 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, consistent-return, prefer-arrow-callback, no-return-assign, object-shorthand, comma-dangle, no-param-reassign, max-len */
-(function() {
- (function(w) {
- var notificationGranted, notifyMe, notifyPermissions;
- notificationGranted = function(message, opts, onclick) {
- var notification;
- notification = new Notification(message, opts);
- setTimeout(function() {
- return notification.close();
- // Hide the notification after X amount of seconds
- }, 8000);
- if (onclick) {
- return notification.onclick = onclick;
- }
- };
- notifyPermissions = function() {
- if ('Notification' in window) {
- return Notification.requestPermission();
- }
- };
- notifyMe = function(message, body, icon, onclick) {
- var opts;
- opts = {
- body: body,
- icon: icon
- };
- // Let's check if the browser supports notifications
- if (!('Notification' in window)) {
+function notificationGranted(message, opts, onclick) {
+ var notification;
+ notification = new Notification(message, opts);
+ setTimeout(function() {
+ // Hide the notification after X amount of seconds
+ return notification.close();
+ }, 8000);
+
+ return notification.onclick = onclick || notification.close;
+}
- // do nothing
- } else if (Notification.permission === 'granted') {
- // If it's okay let's create a notification
+function notifyPermissions() {
+ if ('Notification' in window) {
+ return Notification.requestPermission();
+ }
+}
+
+function notifyMe(message, body, icon, onclick) {
+ var opts;
+ opts = {
+ body: body,
+ icon: icon
+ };
+ // Let's check if the browser supports notifications
+ if (!('Notification' in window)) {
+ // do nothing
+ } else if (Notification.permission === 'granted') {
+ // If it's okay let's create a notification
+ return notificationGranted(message, opts, onclick);
+ } else if (Notification.permission !== 'denied') {
+ return Notification.requestPermission(function(permission) {
+ // If the user accepts, let's create a notification
+ if (permission === 'granted') {
return notificationGranted(message, opts, onclick);
- } else if (Notification.permission !== 'denied') {
- return Notification.requestPermission(function(permission) {
- // If the user accepts, let's create a notification
- if (permission === 'granted') {
- return notificationGranted(message, opts, onclick);
- }
- });
}
- };
- w.notify = notifyMe;
- return w.notifyPermissions = notifyPermissions;
- })(window);
-}).call(window);
+ });
+ }
+}
+
+const notify = {
+ notificationGranted,
+ notifyPermissions,
+ notifyMe,
+};
+
+export default notify;
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index f1b07408671..57394097944 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -42,3 +42,13 @@ export function formatRelevantDigits(number) {
export function bytesToKiB(number) {
return number / BYTES_IN_KIB;
}
+
+/**
+ * Utility function that calculates MiB of the given bytes.
+ *
+ * @param {Number} number bytes
+ * @return {Number} MiB
+ */
+export function bytesToMiB(number) {
+ return number / (BYTES_IN_KIB * BYTES_IN_KIB);
+}
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index fecd531328d..601d01e1be1 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -1,5 +1,6 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len, vars-on-top */
-require('vendor/latinise');
+
+import 'vendor/latinise';
var base;
var w = window;
@@ -169,7 +170,7 @@ gl.text.init = function(form) {
});
};
gl.text.removeListeners = function(form) {
- return $('.js-md', form).off();
+ return $('.js-md', form).off('click');
};
gl.text.humanize = function(string) {
return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
diff --git a/app/assets/javascripts/lib/utils/type_utility.js b/app/assets/javascripts/lib/utils/type_utility.js
index db62e0be324..be86f336bcd 100644
--- a/app/assets/javascripts/lib/utils/type_utility.js
+++ b/app/assets/javascripts/lib/utils/type_utility.js
@@ -1,15 +1,2 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, no-return-assign, max-len */
-(function() {
- (function(w) {
- var base;
- if (w.gl == null) {
- w.gl = {};
- }
- if ((base = w.gl).utils == null) {
- base.utils = {};
- }
- return w.gl.utils.isObject = function(obj) {
- return (obj != null) && (obj.constructor === Object);
- };
- })(window);
-}).call(window);
+// eslint-disable-next-line import/prefer-default-export
+export const isObject = obj => obj && obj.constructor === Object;
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index b9d2fc25c39..3328ff9cc23 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -66,7 +66,8 @@ w.gl.utils.removeParamQueryString = function(url, param) {
})()).join('&');
};
w.gl.utils.removeParams = (params) => {
- const url = new URL(window.location.href);
+ const url = document.createElement('a');
+ url.href = window.location.href;
params.forEach((param) => {
url.search = w.gl.utils.removeParamQueryString(url.search, param);
});
diff --git a/app/assets/javascripts/lib/utils/users_cache.js b/app/assets/javascripts/lib/utils/users_cache.js
new file mode 100644
index 00000000000..88f8a622c00
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/users_cache.js
@@ -0,0 +1,28 @@
+import Api from '../../api';
+import Cache from './cache';
+
+class UsersCache extends Cache {
+ retrieve(username) {
+ if (this.hasData(username)) {
+ return Promise.resolve(this.get(username));
+ }
+
+ return Api.users('', { username })
+ .then((users) => {
+ if (!users.length) {
+ throw new Error(`User "${username}" could not be found!`);
+ }
+
+ if (users.length > 1) {
+ throw new Error(`Expected username "${username}" to be unique!`);
+ }
+
+ const user = users[0];
+ this.internalStorage[username] = user;
+ return user;
+ });
+ // missing catch is intentional, error handling depends on use case
+ }
+}
+
+export default new UsersCache();
diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js
index 3ac6dedf131..7400c22543f 100644
--- a/app/assets/javascripts/line_highlighter.js
+++ b/app/assets/javascripts/line_highlighter.js
@@ -4,8 +4,6 @@
//
// Handles single- and multi-line selection and highlight for blob views.
//
-require('vendor/jquery.scrollTo');
-
//
// ### Example Markup
//
@@ -31,8 +29,6 @@ require('vendor/jquery.scrollTo');
// </div>
//
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.LineHighlighter = (function() {
// CSS class applied to highlighted lines
LineHighlighter.prototype.highlightClass = 'hll';
@@ -47,9 +43,9 @@ require('vendor/jquery.scrollTo');
// hash - String URL hash for dependency injection in tests
hash = location.hash;
}
- this.setHash = bind(this.setHash, this);
- this.highlightLine = bind(this.highlightLine, this);
- this.clickHandler = bind(this.clickHandler, this);
+ this.setHash = this.setHash.bind(this);
+ this.highlightLine = this.highlightLine.bind(this);
+ this.clickHandler = this.clickHandler.bind(this);
this.highlightHash = this.highlightHash.bind(this);
this._hash = hash;
this.bindEvents();
diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js
index 9411f078ecf..960b8e5ecb3 100644
--- a/app/assets/javascripts/locale/de/app.js
+++ b/app/assets/javascripts/locale/de/app.js
@@ -1 +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-05-09 13:44+0200","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;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["Von"],"Commit":["Commit","Commits"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics liefern einen Überblick darüber, wie viel Zeit in Ihrem Projekt von einer Idee bis zum Produktivdeployment vergeht."],"CycleAnalyticsStage|Code":["Code"],"CycleAnalyticsStage|Issue":["Issue"],"CycleAnalyticsStage|Plan":["Planung"],"CycleAnalyticsStage|Production":["Produktiv"],"CycleAnalyticsStage|Review":["Review"],"CycleAnalyticsStage|Staging":["Staging"],"CycleAnalyticsStage|Test":["Test"],"Deploy":["Deployment","Deployments"],"FirstPushedBy|First":["Erster"],"FirstPushedBy|pushed by":["gepusht von"],"From issue creation until deploy to production":["Vom Anlegen des Issues bis zum Produktivdeployment"],"From merge request merge until deploy to production":["Vom Merge Request bis zum Produktivdeployment"],"Introducing Cycle Analytics":["Was sind Cycle Analytics?"],"Last %d day":["Letzter %d Tag","Letzten %d Tage"],"Limited to showing %d event at most":["Eingeschränkt auf maximal %d Ereignis","Eingeschränkt auf maximal %d Ereignisse"],"Median":["Median"],"New Issue":["Neues Issue","Neue Issues"],"Not available":["Nicht verfügbar"],"Not enough data":["Nicht genügend Daten"],"OpenedNDaysAgo|Opened":["Erstellt"],"Pipeline Health":["Pipeline Kennzahlen"],"ProjectLifecycle|Stage":["Phase"],"Read more":["Mehr"],"Related Commits":["Zugehörige Commits"],"Related Deployed Jobs":["Zugehörige Deploymentjobs"],"Related Issues":["Zugehörige Issues"],"Related Jobs":["Zugehörige Jobs"],"Related Merge Requests":["Zugehörige Merge Requests"],"Related Merged Requests":["Zugehörige abgeschlossene Merge Requests"],"Showing %d event":["Zeige %d Ereignis","Zeige %d Ereignisse"],"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.":["Die Code-Phase stellt die Zeit vom ersten Commit bis zum Erstellen eines Merge Requests dar. Sobald Sie Ihren ersten Merge Request anlegen, werden dessen Daten automatisch ergänzt."],"The collection of events added to the data gathered for that stage.":["Ereignisse, die für diese Phase ausgewertet wurden."],"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.":["Die Issue-Phase stellt die Zeit vom Anlegen eines Issues bis zum Zuweisen eines Meilensteins oder Hinzufügen zum Issue Board dar. Erstellen Sie einen Issue, damit dessen Daten hier erscheinen."],"The phase of the development lifecycle.":["Die Phase im Entwicklungsprozess."],"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.":["Die Planungsphase stellt die Zeit von der vorherigen Phase bis zum Pushen des ersten Commits dar. Sobald Sie den ersten Commit pushen, werden dessen Daten hier erscheinen."],"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.":["Die Produktiv-Phase stellt die Gesamtzeit vom Anlegen eines Issues bis zum Deployment auf dem Produktivsystem dar. Sobald Sie den vollständigen Entwicklungszyklus von einer Idee bis zum Produktivdeployment durchlaufen haben, erscheinen die zugehörigen Daten hier."],"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.":["Die Review-Phase stellt die Zeit vom Anlegen eines Merge Requests bis zum Mergen dar. Sobald Sie Ihren ersten Merge Request abschließen, werden dessen Daten hier automatisch angezeigt."],"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.":["Die Staging-Phase stellt die Zeit zwischen Mergen eines Merge Requests und dem Produktivdeployment dar. Sobald Sie das erste Produktivdeployment durchgeführt haben, werden dessen Daten hier automatisch angezeigt."],"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.":["Die Test-Phase stellt die Zeit dar, die GitLab CI benötigt um die Pipelines von Merge Requests abzuarbeiten. Sobald die erste Pipeline abgeschlossen ist, werden deren Daten hier automatisch angezeigt."],"The time taken by each data entry gathered by that stage.":["Zeit die für das jeweilige Ereignis in der Phase ermittelt wurde."],"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.":["Der mittlere aller erfassten Werte. Zum Beispiel ist für 3, 5, 9 der Median 5. Bei 3, 5, 7, 8 ist der Median (5+7)/2 = 6."],"Time before an issue gets scheduled":["Zeit bis ein Issue geplant wird"],"Time before an issue starts implementation":["Zeit bis die Implementierung für ein Issue beginnt"],"Time between merge request creation and merge/close":["Zeit zwischen Anlegen und Mergen/Schließen eines Merge Requests"],"Time until first merge request":["Zeit bis zum ersten Merge Request"],"Time|hr":["h","h"],"Time|min":["min","min"],"Time|s":["s"],"Total Time":["Gesamtzeit"],"Total test time for all commits/merges":["Gesamte Testlaufzeit für alle Commits/Merges"],"Want to see the data? Please ask an administrator for access.":["Um diese Daten einsehen zu können, wenden Sie sich bitte an Ihren Administrator."],"We don't have enough data to show this stage.":["Es liegen nicht genügend Daten vor, um diese Phase anzuzeigen."],"You need permission.":["Sie benötigen Zugriffsrechte."],"day":["Tag","Tage"]}}}; \ No newline at end of file
+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-06-07 12:17+0200","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;","Last-Translator":"Bob Van Landuyt <bob@gitlab.com>","X-Generator":"Poedit 2.0.2","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"About auto deploy":[""],"Activity":[""],"Add Changelog":[""],"Add Contribution guide":[""],"Add License":[""],"Add an SSH key to your profile to pull or push via SSH.":[""],"Add new directory":[""],"Archived project! Repository is read-only":[""],"Branch":["",""],"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}":[""],"Branches":[""],"ByAuthor|by":["Von"],"CI configuration":[""],"Changelog":[""],"Charts":[""],"CiStatusLabel|canceled":[""],"CiStatusLabel|created":[""],"CiStatusLabel|failed":[""],"CiStatusLabel|manual action":[""],"CiStatusLabel|passed":[""],"CiStatusLabel|passed with warnings":[""],"CiStatusLabel|pending":[""],"CiStatusLabel|skipped":[""],"CiStatusLabel|waiting for manual action":[""],"CiStatusText|blocked":[""],"CiStatusText|canceled":[""],"CiStatusText|created":[""],"CiStatusText|failed":[""],"CiStatusText|manual":[""],"CiStatusText|passed":[""],"CiStatusText|pending":[""],"CiStatusText|skipped":[""],"CiStatus|running":[""],"Commit":["Commit","Commits"],"CommitMessage|Add %{file_name}":[""],"Commits":["Commits"],"Commits|History":["Commits"],"Compare":[""],"Contribution guide":[""],"Contributors":[""],"Copy URL to clipboard":[""],"Copy commit SHA to clipboard":[""],"Create New Directory":[""],"Create directory":[""],"Create empty bare repository":[""],"Create merge request":[""],"CreateNewFork|Fork":[""],"Custom notification events":[""],"Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.":[""],"Cycle Analytics":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics liefern einen Überblick darüber, wie viel Zeit in Ihrem Projekt von einer Idee bis zum Produktivdeployment vergeht."],"CycleAnalyticsStage|Code":["Code"],"CycleAnalyticsStage|Issue":["Issue"],"CycleAnalyticsStage|Plan":["Planung"],"CycleAnalyticsStage|Production":["Produktiv"],"CycleAnalyticsStage|Review":["Review"],"CycleAnalyticsStage|Staging":["Staging"],"CycleAnalyticsStage|Test":["Test"],"Deploy":["Deployment","Deployments"],"Directory name":[""],"Don't show again":[""],"Download tar":[""],"Download tar.bz2":[""],"Download tar.gz":[""],"Download zip":[""],"DownloadArtifacts|Download":[""],"DownloadSource|Download":[""],"Files":[""],"Find by path":[""],"Find file":[""],"FirstPushedBy|First":["Erster"],"FirstPushedBy|pushed by":["gepusht von"],"ForkedFromProjectPath|Forked from":[""],"Forks":[""],"From issue creation until deploy to production":["Vom Anlegen des Issues bis zum Produktivdeployment"],"From merge request merge until deploy to production":["Vom Merge Request bis zum Produktivdeployment"],"Go to your fork":[""],"GoToYourFork|Fork":[""],"Home":[""],"Housekeeping successfully started":[""],"Import repository":[""],"Introducing Cycle Analytics":["Was sind Cycle Analytics?"],"LFSStatus|Disabled":[""],"LFSStatus|Enabled":[""],"Last %d day":["Letzter %d Tag","Letzten %d Tage"],"Last Update":[""],"Last commit":[""],"Leave group":[""],"Leave project":[""],"Limited to showing %d event at most":["Eingeschränkt auf maximal %d Ereignis","Eingeschränkt auf maximal %d Ereignisse"],"Median":["Median"],"MissingSSHKeyWarningLink|add an SSH key":[""],"New Issue":["Neues Issue","Neue Issues"],"New branch":[""],"New directory":[""],"New file":[""],"New issue":["Neues Issue"],"New merge request":[""],"New snippet":[""],"New tag":[""],"No repository":[""],"Not available":["Nicht verfügbar"],"Not enough data":["Nicht genügend Daten"],"Notification events":[""],"NotificationEvent|Close issue":[""],"NotificationEvent|Close merge request":[""],"NotificationEvent|Failed pipeline":[""],"NotificationEvent|Merge merge request":[""],"NotificationEvent|New issue":[""],"NotificationEvent|New merge request":[""],"NotificationEvent|New note":[""],"NotificationEvent|Reassign issue":[""],"NotificationEvent|Reassign merge request":[""],"NotificationEvent|Reopen issue":[""],"NotificationEvent|Successful pipeline":[""],"NotificationLevel|Custom":[""],"NotificationLevel|Disabled":[""],"NotificationLevel|Global":[""],"NotificationLevel|On mention":[""],"NotificationLevel|Participate":[""],"NotificationLevel|Watch":[""],"OpenedNDaysAgo|Opened":["Erstellt"],"Pipeline Health":["Pipeline Kennzahlen"],"Project '%{project_name}' queued for deletion.":[""],"Project '%{project_name}' was successfully created.":[""],"Project '%{project_name}' was successfully updated.":[""],"Project '%{project_name}' will be deleted.":[""],"Project access must be granted explicitly to each user.":[""],"Project export could not be deleted.":[""],"Project export has been deleted.":[""],"Project export link has expired. Please generate a new export from your project settings.":[""],"Project export started. A download link will be sent by email.":[""],"Project home":[""],"ProjectFeature|Disabled":[""],"ProjectFeature|Everyone with access":[""],"ProjectFeature|Only team members":[""],"ProjectFileTree|Name":[""],"ProjectLastActivity|Never":[""],"ProjectLifecycle|Stage":["Phase"],"ProjectNetworkGraph|Graph":[""],"Read more":["Mehr"],"Readme":[""],"RefSwitcher|Branches":[""],"RefSwitcher|Tags":[""],"Related Commits":["Zugehörige Commits"],"Related Deployed Jobs":["Zugehörige Deploymentjobs"],"Related Issues":["Zugehörige Issues"],"Related Jobs":["Zugehörige Jobs"],"Related Merge Requests":["Zugehörige Merge Requests"],"Related Merged Requests":["Zugehörige abgeschlossene Merge Requests"],"Remind later":[""],"Remove project":[""],"Request Access":[""],"Search branches and tags":[""],"Select Archive Format":[""],"Set a password on your account to pull or push via %{protocol}":[""],"Set up CI":[""],"Set up Koding":[""],"Set up auto deploy":[""],"SetPasswordToCloneLink|set a password":[""],"Showing %d event":["Zeige %d Ereignis","Zeige %d Ereignisse"],"Source code":[""],"StarProject|Star":[""],"Switch branch/tag":[""],"Tag":["",""],"Tags":[""],"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.":["Die Code-Phase stellt die Zeit vom ersten Commit bis zum Erstellen eines Merge Requests dar. Sobald Sie Ihren ersten Merge Request anlegen, werden dessen Daten automatisch ergänzt."],"The collection of events added to the data gathered for that stage.":["Ereignisse, die für diese Phase ausgewertet wurden."],"The fork relationship has been removed.":[""],"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.":["Die Issue-Phase stellt die Zeit vom Anlegen eines Issues bis zum Zuweisen eines Meilensteins oder Hinzufügen zum Issue Board dar. Erstellen Sie einen Issue, damit dessen Daten hier erscheinen."],"The phase of the development lifecycle.":["Die Phase im Entwicklungsprozess."],"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.":["Die Planungsphase stellt die Zeit von der vorherigen Phase bis zum Pushen des ersten Commits dar. Sobald Sie den ersten Commit pushen, werden dessen Daten hier erscheinen."],"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.":["Die Produktiv-Phase stellt die Gesamtzeit vom Anlegen eines Issues bis zum Deployment auf dem Produktivsystem dar. Sobald Sie den vollständigen Entwicklungszyklus von einer Idee bis zum Produktivdeployment durchlaufen haben, erscheinen die zugehörigen Daten hier."],"The project can be accessed by any logged in user.":[""],"The project can be accessed without any authentication.":[""],"The repository for this project does not exist.":[""],"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.":["Die Review-Phase stellt die Zeit vom Anlegen eines Merge Requests bis zum Mergen dar. Sobald Sie Ihren ersten Merge Request abschließen, werden dessen Daten hier automatisch angezeigt."],"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.":["Die Staging-Phase stellt die Zeit zwischen Mergen eines Merge Requests und dem Produktivdeployment dar. Sobald Sie das erste Produktivdeployment durchgeführt haben, werden dessen Daten hier automatisch angezeigt."],"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.":["Die Test-Phase stellt die Zeit dar, die GitLab CI benötigt um die Pipelines von Merge Requests abzuarbeiten. Sobald die erste Pipeline abgeschlossen ist, werden deren Daten hier automatisch angezeigt."],"The time taken by each data entry gathered by that stage.":["Zeit die für das jeweilige Ereignis in der Phase ermittelt wurde."],"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.":["Der mittlere aller erfassten Werte. Zum Beispiel ist für 3, 5, 9 der Median 5. Bei 3, 5, 7, 8 ist der Median (5+7)/2 = 6."],"This means you can not push code until you create an empty repository or import existing one.":[""],"Time before an issue gets scheduled":["Zeit bis ein Issue geplant wird"],"Time before an issue starts implementation":["Zeit bis die Implementierung für ein Issue beginnt"],"Time between merge request creation and merge/close":["Zeit zwischen Anlegen und Mergen/Schließen eines Merge Requests"],"Time until first merge request":["Zeit bis zum ersten Merge Request"],"Timeago|%s days ago":[""],"Timeago|%s days remaining":[""],"Timeago|%s hours remaining":[""],"Timeago|%s minutes ago":[""],"Timeago|%s minutes remaining":[""],"Timeago|%s months ago":[""],"Timeago|%s months remaining":[""],"Timeago|%s seconds remaining":[""],"Timeago|%s weeks ago":[""],"Timeago|%s weeks remaining":[""],"Timeago|%s years ago":[""],"Timeago|%s years remaining":[""],"Timeago|1 day remaining":[""],"Timeago|1 hour remaining":[""],"Timeago|1 minute remaining":[""],"Timeago|1 month remaining":[""],"Timeago|1 week remaining":[""],"Timeago|1 year remaining":[""],"Timeago|Past due":[""],"Timeago|a day ago":[""],"Timeago|a month ago":[""],"Timeago|a week ago":[""],"Timeago|a while":[""],"Timeago|a year ago":[""],"Timeago|about %s hours ago":[""],"Timeago|about a minute ago":[""],"Timeago|about an hour ago":[""],"Timeago|in %s days":[""],"Timeago|in %s hours":[""],"Timeago|in %s minutes":[""],"Timeago|in %s months":[""],"Timeago|in %s seconds":[""],"Timeago|in %s weeks":[""],"Timeago|in %s years":[""],"Timeago|in 1 day":[""],"Timeago|in 1 hour":[""],"Timeago|in 1 minute":[""],"Timeago|in 1 month":[""],"Timeago|in 1 week":[""],"Timeago|in 1 year":[""],"Timeago|less than a minute ago":[""],"Time|hr":["h","h"],"Time|min":["min","min"],"Time|s":["s"],"Total Time":["Gesamtzeit"],"Total test time for all commits/merges":["Gesamte Testlaufzeit für alle Commits/Merges"],"Unstar":[""],"Upload New File":[""],"Upload file":[""],"Use your global notification setting":[""],"VisibilityLevel|Internal":[""],"VisibilityLevel|Private":[""],"VisibilityLevel|Public":[""],"Want to see the data? Please ask an administrator for access.":["Um diese Daten einsehen zu können, wenden Sie sich bitte an Ihren Administrator."],"We don't have enough data to show this stage.":["Es liegen nicht genügend Daten vor, um diese Phase anzuzeigen."],"Withdraw Access Request":[""],"You are going to remove %{project_name_with_namespace}.\\nRemoved project CANNOT be restored!\\nAre you ABSOLUTELY sure?":[""],"You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?":[""],"You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?":[""],"You can only add files when you are on a branch":[""],"You must sign in to star a project":[""],"You need permission.":["Sie benötigen Zugriffsrechte."],"You will not get any notifications via email":[""],"You will only receive notifications for the events you choose":[""],"You will only receive notifications for threads you have participated in":[""],"You will receive notifications for any activity":[""],"You will receive notifications only for comments in which you were @mentioned":[""],"You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account":[""],"You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile":[""],"Your name":[""],"committed":[""],"day":["Tag","Tage"],"notification emails":[""]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js
index ade9b667b3c..80203308b00 100644
--- a/app/assets/javascripts/locale/en/app.js
+++ b/app/assets/javascripts/locale/en/app.js
@@ -1 +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
+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-06-07 12:14+0200","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;","Last-Translator":"Bob Van Landuyt <bob@gitlab.com>","X-Generator":"Poedit 2.0.2","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"About auto deploy":[""],"Activity":[""],"Add Changelog":[""],"Add Contribution guide":[""],"Add License":[""],"Add an SSH key to your profile to pull or push via SSH.":[""],"Add new directory":[""],"Archived project! Repository is read-only":[""],"Branch":["",""],"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}":[""],"Branches":[""],"ByAuthor|by":[""],"CI configuration":[""],"Changelog":[""],"Charts":[""],"CiStatusLabel|canceled":[""],"CiStatusLabel|created":[""],"CiStatusLabel|failed":[""],"CiStatusLabel|manual action":[""],"CiStatusLabel|passed":[""],"CiStatusLabel|passed with warnings":[""],"CiStatusLabel|pending":[""],"CiStatusLabel|skipped":[""],"CiStatusLabel|waiting for manual action":[""],"CiStatusText|blocked":[""],"CiStatusText|canceled":[""],"CiStatusText|created":[""],"CiStatusText|failed":[""],"CiStatusText|manual":[""],"CiStatusText|passed":[""],"CiStatusText|pending":[""],"CiStatusText|skipped":[""],"CiStatus|running":[""],"Commit":["",""],"CommitMessage|Add %{file_name}":[""],"Commits":[""],"Commits|History":[""],"Compare":[""],"Contribution guide":[""],"Contributors":[""],"Copy URL to clipboard":[""],"Copy commit SHA to clipboard":[""],"Create New Directory":[""],"Create directory":[""],"Create empty bare repository":[""],"Create merge request":[""],"CreateNewFork|Fork":[""],"Custom notification events":[""],"Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.":[""],"Cycle Analytics":[""],"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":["",""],"Directory name":[""],"Don't show again":[""],"Download tar":[""],"Download tar.bz2":[""],"Download tar.gz":[""],"Download zip":[""],"DownloadArtifacts|Download":[""],"DownloadSource|Download":[""],"Files":[""],"Find by path":[""],"Find file":[""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"ForkedFromProjectPath|Forked from":[""],"Forks":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Go to your fork":[""],"GoToYourFork|Fork":[""],"Home":[""],"Housekeeping successfully started":[""],"Import repository":[""],"Introducing Cycle Analytics":[""],"LFSStatus|Disabled":[""],"LFSStatus|Enabled":[""],"Last %d day":["",""],"Last Update":[""],"Last commit":[""],"Leave group":[""],"Leave project":[""],"Limited to showing %d event at most":["",""],"Median":[""],"MissingSSHKeyWarningLink|add an SSH key":[""],"New Issue":["",""],"New branch":[""],"New directory":[""],"New file":[""],"New issue":[""],"New merge request":[""],"New snippet":[""],"New tag":[""],"No repository":[""],"Not available":[""],"Not enough data":[""],"Notification events":[""],"NotificationEvent|Close issue":[""],"NotificationEvent|Close merge request":[""],"NotificationEvent|Failed pipeline":[""],"NotificationEvent|Merge merge request":[""],"NotificationEvent|New issue":[""],"NotificationEvent|New merge request":[""],"NotificationEvent|New note":[""],"NotificationEvent|Reassign issue":[""],"NotificationEvent|Reassign merge request":[""],"NotificationEvent|Reopen issue":[""],"NotificationEvent|Successful pipeline":[""],"NotificationLevel|Custom":[""],"NotificationLevel|Disabled":[""],"NotificationLevel|Global":[""],"NotificationLevel|On mention":[""],"NotificationLevel|Participate":[""],"NotificationLevel|Watch":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"Project '%{project_name}' queued for deletion.":[""],"Project '%{project_name}' was successfully created.":[""],"Project '%{project_name}' was successfully updated.":[""],"Project '%{project_name}' will be deleted.":[""],"Project access must be granted explicitly to each user.":[""],"Project export could not be deleted.":[""],"Project export has been deleted.":[""],"Project export link has expired. Please generate a new export from your project settings.":[""],"Project export started. A download link will be sent by email.":[""],"Project home":[""],"ProjectFeature|Disabled":[""],"ProjectFeature|Everyone with access":[""],"ProjectFeature|Only team members":[""],"ProjectFileTree|Name":[""],"ProjectLastActivity|Never":[""],"ProjectLifecycle|Stage":[""],"ProjectNetworkGraph|Graph":[""],"Read more":[""],"Readme":[""],"RefSwitcher|Branches":[""],"RefSwitcher|Tags":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Remind later":[""],"Remove project":[""],"Request Access":[""],"Search branches and tags":[""],"Select Archive Format":[""],"Set a password on your account to pull or push via %{protocol}":[""],"Set up CI":[""],"Set up Koding":[""],"Set up auto deploy":[""],"SetPasswordToCloneLink|set a password":[""],"Showing %d event":["",""],"Source code":[""],"StarProject|Star":[""],"Switch branch/tag":[""],"Tag":["",""],"Tags":[""],"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 fork relationship has been removed.":[""],"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 project can be accessed by any logged in user.":[""],"The project can be accessed without any authentication.":[""],"The repository for this project does not exist.":[""],"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.":[""],"This means you can not push code until you create an empty repository or import existing one.":[""],"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":[""],"Timeago|%s days ago":[""],"Timeago|%s days remaining":[""],"Timeago|%s hours remaining":[""],"Timeago|%s minutes ago":[""],"Timeago|%s minutes remaining":[""],"Timeago|%s months ago":[""],"Timeago|%s months remaining":[""],"Timeago|%s seconds remaining":[""],"Timeago|%s weeks ago":[""],"Timeago|%s weeks remaining":[""],"Timeago|%s years ago":[""],"Timeago|%s years remaining":[""],"Timeago|1 day remaining":[""],"Timeago|1 hour remaining":[""],"Timeago|1 minute remaining":[""],"Timeago|1 month remaining":[""],"Timeago|1 week remaining":[""],"Timeago|1 year remaining":[""],"Timeago|Past due":[""],"Timeago|a day ago":[""],"Timeago|a month ago":[""],"Timeago|a week ago":[""],"Timeago|a while":[""],"Timeago|a year ago":[""],"Timeago|about %s hours ago":[""],"Timeago|about a minute ago":[""],"Timeago|about an hour ago":[""],"Timeago|in %s days":[""],"Timeago|in %s hours":[""],"Timeago|in %s minutes":[""],"Timeago|in %s months":[""],"Timeago|in %s seconds":[""],"Timeago|in %s weeks":[""],"Timeago|in %s years":[""],"Timeago|in 1 day":[""],"Timeago|in 1 hour":[""],"Timeago|in 1 minute":[""],"Timeago|in 1 month":[""],"Timeago|in 1 week":[""],"Timeago|in 1 year":[""],"Timeago|less than a minute ago":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Unstar":[""],"Upload New File":[""],"Upload file":[""],"Use your global notification setting":[""],"VisibilityLevel|Internal":[""],"VisibilityLevel|Private":[""],"VisibilityLevel|Public":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"Withdraw Access Request":[""],"You are going to remove %{project_name_with_namespace}.\\nRemoved project CANNOT be restored!\\nAre you ABSOLUTELY sure?":[""],"You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?":[""],"You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?":[""],"You can only add files when you are on a branch":[""],"You must sign in to star a project":[""],"You need permission.":[""],"You will not get any notifications via email":[""],"You will only receive notifications for the events you choose":[""],"You will only receive notifications for threads you have participated in":[""],"You will receive notifications for any activity":[""],"You will receive notifications only for comments in which you were @mentioned":[""],"You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account":[""],"You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile":[""],"Your name":[""],"committed":[""],"day":["",""],"notification emails":[""]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js
index 3dafa21f235..6977625f4d8 100644
--- a/app/assets/javascripts/locale/es/app.js
+++ b/app/assets/javascripts/locale/es/app.js
@@ -1 +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
+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-06-07 12:29-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":"Bob Van Landuyt <bob@gitlab.com>","X-Generator":"Poedit 2.0.2","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"About auto deploy":["Acerca del auto despliegue"],"Activity":["Actividad"],"Add Changelog":["Agregar Changelog"],"Add Contribution guide":["Agregar guía de contribución"],"Add License":["Agregar Licencia"],"Add an SSH key to your profile to pull or push via SSH.":["Agregar una clave SSH a tu perfil para actualizar o enviar a través de SSH."],"Add new directory":["Agregar nuevo directorio"],"Archived project! Repository is read-only":["¡Proyecto archivado! El repositorio es de sólo lectura"],"Branch":["Rama","Ramas"],"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}":["La rama <strong>%{branch_name}</strong> fue creada. Para configurar el auto despliegue, escoge una plantilla Yaml para GitLab CI y envía tus cambios. %{link_to_autodeploy_doc}"],"Branches":["Ramas"],"ByAuthor|by":["por"],"CI configuration":["Configuración de CI"],"Changelog":["Changelog"],"Charts":["Gráficos"],"CiStatusLabel|canceled":["cancelado"],"CiStatusLabel|created":["creado"],"CiStatusLabel|failed":["fallado"],"CiStatusLabel|manual action":["acción manual"],"CiStatusLabel|passed":["pasó"],"CiStatusLabel|passed with warnings":["pasó con advertencias"],"CiStatusLabel|pending":["pendiente"],"CiStatusLabel|skipped":["omitido"],"CiStatusLabel|waiting for manual action":["esperando acción manual"],"CiStatusText|blocked":["bloqueado"],"CiStatusText|canceled":["cancelado"],"CiStatusText|created":["creado"],"CiStatusText|failed":["fallado"],"CiStatusText|manual":["manual"],"CiStatusText|passed":["pasó"],"CiStatusText|pending":["pendiente"],"CiStatusText|skipped":["omitido"],"CiStatus|running":["en ejecución"],"Commit":["Cambio","Cambios"],"CommitMessage|Add %{file_name}":["Agregar %{file_name}"],"Commits":["Cambios"],"Commits|History":["Historial"],"Compare":["Comparar"],"Contribution guide":["Guía de contribución"],"Contributors":["Contribuidores"],"Copy URL to clipboard":["Copiar URL al portapapeles"],"Copy commit SHA to clipboard":["Copiar SHA del cambio al portapapeles"],"Create New Directory":["Crear Nuevo Directorio"],"Create directory":["Crear directorio"],"Create empty bare repository":["Crear repositorio vacío"],"Create merge request":["Crear solicitud de fusión"],"CreateNewFork|Fork":["Bifurcar"],"Custom notification events":["Eventos de notificaciones personalizadas"],"Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.":["Los niveles de notificación personalizados son los mismos que los niveles participantes. Con los niveles de notificación personalizados, también recibirá notificaciones para eventos seleccionados. Para obtener más información, consulte %{notification_link}."],"Cycle Analytics":["Cycle Analytics"],"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"],"Directory name":["Nombre del directorio"],"Don't show again":["No mostrar de nuevo"],"Download tar":["Descargar tar"],"Download tar.bz2":["Descargar tar.bz2"],"Download tar.gz":["Descargar tar.gz"],"Download zip":["Descargar zip"],"DownloadArtifacts|Download":["Descargar"],"DownloadSource|Download":["Descargar"],"Files":["Archivos"],"Find by path":["Buscar por ruta"],"Find file":["Buscar archivo"],"FirstPushedBy|First":["Primer"],"FirstPushedBy|pushed by":["enviado por"],"ForkedFromProjectPath|Forked from":["Bifurcado de"],"Forks":["Bifurcaciones"],"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"],"Go to your fork":["Ir a tu bifurcación"],"GoToYourFork|Fork":["Bifurcación"],"Home":["Inicio"],"Housekeeping successfully started":["Servicio de limpieza iniciado con éxito"],"Import repository":["Importar repositorio"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"LFSStatus|Disabled":["Deshabilitado"],"LFSStatus|Enabled":["Habilitado"],"Last %d day":["Último %d día","Últimos %d días"],"Last Update":["Última actualización"],"Last commit":["Último cambio"],"Leave group":["Abandonar grupo"],"Leave project":["Abandonar proyecto"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d evento","Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"MissingSSHKeyWarningLink|add an SSH key":["agregar una clave SSH"],"New Issue":["Nueva incidencia","Nuevas incidencias"],"New branch":["Nueva rama"],"New directory":["Nuevo directorio"],"New file":["Nuevo archivo"],"New issue":["Nueva incidencia"],"New merge request":["Nueva solicitud de fusión"],"New snippet":["Nuevo fragmento de código"],"New tag":["Nueva etiqueta"],"No repository":["No hay repositorio"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"Notification events":["Eventos de notificación"],"NotificationEvent|Close issue":["Cerrar incidencia"],"NotificationEvent|Close merge request":["Cerrar solicitud de fusión"],"NotificationEvent|Failed pipeline":["Pipeline fallido"],"NotificationEvent|Merge merge request":["Integrar solicitud de fusión"],"NotificationEvent|New issue":["Nueva incidencia"],"NotificationEvent|New merge request":["Nueva solicitud de fusión"],"NotificationEvent|New note":["Nueva nota"],"NotificationEvent|Reassign issue":["Reasignar incidencia"],"NotificationEvent|Reassign merge request":["Reasignar solicitud de fusión"],"NotificationEvent|Reopen issue":["Reabrir incidencia"],"NotificationEvent|Successful pipeline":["Pipeline exitoso"],"NotificationLevel|Custom":["Personalizado"],"NotificationLevel|Disabled":["Deshabilitado"],"NotificationLevel|Global":["Global"],"NotificationLevel|On mention":["Cuando me mencionan"],"NotificationLevel|Participate":["Participación"],"NotificationLevel|Watch":["Vigilancia"],"OpenedNDaysAgo|Opened":["Abierto"],"Pipeline Health":["Estado del Pipeline"],"Project '%{project_name}' queued for deletion.":["Proyecto ‘%{project_name}’ en cola para eliminación."],"Project '%{project_name}' was successfully created.":["Proyecto ‘%{project_name}’ fue creado satisfactoriamente."],"Project '%{project_name}' was successfully updated.":["Proyecto ‘%{project_name}’ fue actualizado satisfactoriamente."],"Project '%{project_name}' will be deleted.":["Proyecto ‘%{project_name}’ será eliminado."],"Project access must be granted explicitly to each user.":["El acceso al proyecto debe concederse explícitamente a cada usuario."],"Project export could not be deleted.":["No se pudo eliminar la exportación del proyecto."],"Project export has been deleted.":["La exportación del proyecto ha sido eliminada."],"Project export link has expired. Please generate a new export from your project settings.":["El enlace de exportación del proyecto ha caducado. Por favor, genera una nueva exportación desde la configuración del proyecto."],"Project export started. A download link will be sent by email.":["Se inició la exportación del proyecto. Se enviará un enlace de descarga por correo electrónico."],"Project home":["Inicio del proyecto"],"ProjectFeature|Disabled":["Deshabilitada"],"ProjectFeature|Everyone with access":["Todos con acceso"],"ProjectFeature|Only team members":["Solo miembros del equipo"],"ProjectFileTree|Name":["Nombre"],"ProjectLastActivity|Never":["Nunca"],"ProjectLifecycle|Stage":["Etapa"],"ProjectNetworkGraph|Graph":["Historial gráfico"],"Read more":["Leer más"],"Readme":["Readme"],"RefSwitcher|Branches":["Ramas"],"RefSwitcher|Tags":["Etiquetas"],"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"],"Remind later":["Recordar después"],"Remove project":["Eliminar proyecto"],"Request Access":["Solicitar acceso"],"Search branches and tags":["Buscar ramas y etiquetas"],"Select Archive Format":["Seleccionar formato de archivo"],"Set a password on your account to pull or push via %{protocol}":["Establezca una contraseña en su cuenta para actualizar o enviar a través de% {protocol}"],"Set up CI":["Configurar CI"],"Set up Koding":["Configurar Koding"],"Set up auto deploy":["Configurar auto despliegue"],"SetPasswordToCloneLink|set a password":["establecer una contraseña"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"Source code":["Código fuente"],"StarProject|Star":["Destacar"],"Switch branch/tag":["Cambiar rama/etiqueta"],"Tag":["Etiqueta","Etiquetas"],"Tags":["Etiquetas"],"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 desarrollo 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 fork relationship has been removed.":["La relación con la bifurcación se ha eliminado."],"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 project can be accessed by any logged in user.":["El proyecto puede ser accedido por cualquier usuario conectado."],"The project can be accessed without any authentication.":["El proyecto puede accederse sin ninguna autenticación."],"The repository for this project does not exist.":["El repositorio para este proyecto no existe."],"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."],"This means you can not push code until you create an empty repository or import existing one.":["Esto significa que no puede enviar código hasta que cree un repositorio vacío o importe uno existente."],"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"],"Timeago|%s days ago":["hace %s días"],"Timeago|%s days remaining":["%s días restantes"],"Timeago|%s hours remaining":["%s horas restantes"],"Timeago|%s minutes ago":["hace %s minutos"],"Timeago|%s minutes remaining":["%s minutos restantes"],"Timeago|%s months ago":["hace %s meses"],"Timeago|%s months remaining":["%s meses restantes"],"Timeago|%s seconds remaining":["%s segundos restantes"],"Timeago|%s weeks ago":["hace %s semanas"],"Timeago|%s weeks remaining":["%s semanas restantes"],"Timeago|%s years ago":["hace %s años"],"Timeago|%s years remaining":["%s años restantes"],"Timeago|1 day remaining":["1 día restante"],"Timeago|1 hour remaining":["1 hora restante"],"Timeago|1 minute remaining":["1 minuto restante"],"Timeago|1 month remaining":["1 mes restante"],"Timeago|1 week remaining":["1 semana restante"],"Timeago|1 year remaining":["1 año restante"],"Timeago|Past due":["Atrasado"],"Timeago|a day ago":["hace un día"],"Timeago|a month ago":["hace 1 mes"],"Timeago|a week ago":["hace 1 semana"],"Timeago|a while":["hace un momento"],"Timeago|a year ago":["hace 1 año"],"Timeago|about %s hours ago":["hace alrededor de %s horas"],"Timeago|about a minute ago":["hace alrededor de 1 minuto"],"Timeago|about an hour ago":["hace alrededor de 1 hora"],"Timeago|in %s days":["en %s días"],"Timeago|in %s hours":["en %s horas"],"Timeago|in %s minutes":["en %s minutos"],"Timeago|in %s months":["en %s meses"],"Timeago|in %s seconds":["en %s segundos"],"Timeago|in %s weeks":["en %s semanas"],"Timeago|in %s years":["en %s años"],"Timeago|in 1 day":["en 1 día"],"Timeago|in 1 hour":["en 1 hora"],"Timeago|in 1 minute":["en 1 minuto"],"Timeago|in 1 month":["en 1 mes"],"Timeago|in 1 week":["en 1 semana"],"Timeago|in 1 year":["en 1 año"],"Timeago|less than a minute ago":["hace menos de 1 minuto"],"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"],"Unstar":["No Destacar"],"Upload New File":["Subir nuevo archivo"],"Upload file":["Subir archivo"],"Use your global notification setting":["Utiliza tu configuración de notificación global"],"VisibilityLevel|Internal":["Interno"],"VisibilityLevel|Private":["Privado"],"VisibilityLevel|Public":["Público"],"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."],"Withdraw Access Request":["Retirar Solicitud de Acceso"],"You are going to remove %{project_name_with_namespace}.\\nRemoved project CANNOT be restored!\\nAre you ABSOLUTELY sure?":["Va a eliminar %{project_name_with_namespace}.\\n¡El proyecto eliminado NO puede ser restaurado!\\n¿Estás TOTALMENTE seguro?"],"You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?":["Vas a eliminar el enlace de la bifurcación con el proyecto original %{forked_from_project}. ¿Estás TOTALMENTE seguro?"],"You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?":["Vas a transferir %{project_name_with_namespace} a otro propietario. ¿Estás TOTALMENTE seguro?"],"You can only add files when you are on a branch":["Sólo puede agregar archivos cuando estas en una rama"],"You must sign in to star a project":["Debes iniciar sesión para destacar un proyecto"],"You need permission.":["Necesitas permisos."],"You will not get any notifications via email":["No recibirás ninguna notificación por correo electrónico"],"You will only receive notifications for the events you choose":["Solo recibirás notificaciones de los eventos que elijas"],"You will only receive notifications for threads you have participated in":["Solo recibirás notificaciones de los temas en los que has participado"],"You will receive notifications for any activity":["Recibirás notificaciones para cualquier actividad"],"You will receive notifications only for comments in which you were @mentioned":["Recibirás notificaciones sólo para los comentarios en los que se te mencionó"],"You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account":["No podrás actualizar o enviar código al proyecto a través de %{protocol} hasta que %{set_password_link} en tu cuenta"],"You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile":["No podrás actualizar o enviar código al proyecto a través de SSH hasta que %{add_ssh_key_link} en su perfil"],"Your name":["Tu nombre"],"committed":["cambió"],"day":["día","días"],"notification emails":["correos electrónicos de notificación"]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/zh_CN/app.js b/app/assets/javascripts/locale/zh_CN/app.js
new file mode 100644
index 00000000000..9525bc88190
--- /dev/null
+++ b/app/assets/javascripts/locale/zh_CN/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['zh_CN'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (China) (https://www.transifex.com/gitlab-zh/teams/75177/zh_CN/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_CN","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_CN","domain":"app","plural_forms":"nplurals=1; plural=0;"},"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":["最后 %d 天"],"Limited to showing %d event at most":["最多显示 %d 个事件"],"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":["显示 %d 个事件"],"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.":["测试阶段概述了GitLab CI为相关合并请求运行每个流水线所需的时间。当第一个流水线运行完成后,数据将自动添加到此处。"],"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.":["中位数是一个数列中最中间的值。例如在 3、5、9 之间,中位数是 5。在 3、5、7、8 之间,中位数是 (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/zh_HK/app.js b/app/assets/javascripts/locale/zh_HK/app.js
new file mode 100644
index 00000000000..fd0bcd988c5
--- /dev/null
+++ b/app/assets/javascripts/locale/zh_HK/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['zh_HK'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (Hong Kong) (https://www.transifex.com/gitlab-zh/teams/75177/zh_HK/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_HK","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_HK","domain":"app","plural_forms":"nplurals=1; plural=0;"},"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":["最後 %d 天"],"Limited to showing %d event at most":["最多顯示 %d 個事件"],"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":["顯示 %d 個事件"],"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.":["測試階段概述了GitLab CI為相關合併請求運行每個流水線所需的時間。當第壹個流水線運行完成後,數據將自動添加到此處。"],"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.":["中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (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/zh_TW/app.js b/app/assets/javascripts/locale/zh_TW/app.js
new file mode 100644
index 00000000000..79904d17bf6
--- /dev/null
+++ b/app/assets/javascripts/locale/zh_TW/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['zh_TW'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (Taiwan) (https://www.transifex.com/gitlab-zh/teams/75177/zh_TW/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_TW","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_TW","domain":"app","plural_forms":"nplurals=1; plural=0;"},"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":["最後 %d 天"],"Limited to showing %d event at most":["最多顯示 %d 個事件"],"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":["顯示 %d 個事件"],"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.":["中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (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/main.js b/app/assets/javascripts/main.js
index a07aa047293..fe367d0c42a 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -56,10 +56,8 @@ import './lib/utils/animate';
import './lib/utils/bootstrap_linked_tabs';
import './lib/utils/common_utils';
import './lib/utils/datetime_utility';
-import './lib/utils/notify';
import './lib/utils/pretty_time';
import './lib/utils/text_utility';
-import './lib/utils/type_utility';
import './lib/utils/url_utility';
// u2f
@@ -97,7 +95,6 @@ import './dropzone_input';
import './due_date_select';
import './files_comment_button';
import './flash';
-import './gfm_auto_complete';
import './gl_dropdown';
import './gl_field_error';
import './gl_field_errors';
@@ -107,12 +104,11 @@ import './group_label_subscription';
import './groups_select';
import './header';
import './importer_status';
-import './issuable';
+import './issuable_index';
import './issuable_context';
import './issuable_form';
import './issue';
import './issue_status_select';
-import './issues_bulk_assignment';
import './label_manager';
import './labels';
import './labels_select';
@@ -172,7 +168,7 @@ import './visibility_select';
import './wikis';
import './zen_mode';
-// eslint-disable-next-line global-require
+// eslint-disable-next-line global-require, import/no-commonjs
if (process.env.NODE_ENV !== 'production') require('./test_utils/');
document.addEventListener('beforeunload', function () {
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
index 15992460146..17030c3e4d3 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
@@ -2,14 +2,13 @@
/* global Flash */
import Vue from 'vue';
-
-require('./merge_conflict_store');
-require('./merge_conflict_service');
-require('./mixins/line_conflict_utils');
-require('./mixins/line_conflict_actions');
-require('./components/diff_file_editor');
-require('./components/inline_conflict_lines');
-require('./components/parallel_conflict_lines');
+import './merge_conflict_store';
+import './merge_conflict_service';
+import './mixins/line_conflict_utils';
+import './mixins/line_conflict_actions';
+import './components/diff_file_editor';
+import './components/inline_conflict_lines';
+import './components/parallel_conflict_lines';
$(() => {
const INTERACTIVE_RESOLVE_MODE = 'interactive';
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index ed342b9990f..f93feeec1c2 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -1,13 +1,11 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, no-underscore-dangle, one-var, one-var-declaration-per-line, consistent-return, dot-notation, quote-props, comma-dangle, object-shorthand, max-len, prefer-arrow-callback */
/* global MergeRequestTabs */
-require('vendor/jquery.waitforimages');
-require('./task_list');
-require('./merge_request_tabs');
+import 'vendor/jquery.waitforimages';
+import './task_list';
+import './merge_request_tabs';
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.MergeRequest = (function() {
function MergeRequest(opts) {
// Initialize MergeRequest behavior
@@ -16,7 +14,7 @@ require('./merge_request_tabs');
// action - String, current controller action
//
this.opts = opts != null ? opts : {};
- this.submitNoteForm = bind(this.submitNoteForm, this);
+ this.submitNoteForm = this.submitNoteForm.bind(this);
this.$el = $('.merge-request');
this.$('.show-all-commits').on('click', (function(_this) {
return function() {
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index c709730f78f..894ed81b044 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -285,7 +285,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
// Similar to `toggler_behavior` in the discussion tab
const hash = window.gl.utils.getLocationHash();
const anchor = hash && $container.find(`[id="${hash}"]`);
- if (anchor) {
+ if (anchor && anchor.length > 0) {
const notesContent = anchor.closest('.notes_content');
const lineType = notesContent.hasClass('new') ? 'new' : 'old';
notes.toggleDiffNote({
@@ -373,18 +373,26 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
initAffix() {
const $tabs = $('.js-tabs-affix');
+ const $fixedNav = $('.navbar-gitlab');
// Screen space on small screens is usually very sparse
// So we dont affix the tabs on these
if (Breakpoints.get().getBreakpointSize() === 'xs' || !$tabs.length) return;
+ /**
+ If the browser does not support position sticky, it returns the position as static.
+ If the browser does support sticky, then we allow the browser to handle it, if not
+ then we default back to Bootstraps affix
+ **/
+ if ($tabs.css('position') !== 'static') return;
+
const $diffTabs = $('#diff-notes-app');
$tabs.off('affix.bs.affix affix-top.bs.affix')
.affix({
offset: {
top: () => (
- $diffTabs.offset().top - $tabs.height()
+ $diffTabs.offset().top - $tabs.height() - $fixedNav.height()
),
},
})
diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js
deleted file mode 100644
index 6f6ae9bde92..00000000000
--- a/app/assets/javascripts/merge_request_widget.js
+++ /dev/null
@@ -1,305 +0,0 @@
-/* eslint-disable max-len, no-var, func-names, space-before-function-paren, vars-on-top, comma-dangle, no-return-assign, consistent-return, no-param-reassign, one-var, one-var-declaration-per-line, quotes, prefer-template, no-else-return, prefer-arrow-callback, no-unused-vars, no-underscore-dangle, no-shadow, no-mixed-operators, camelcase, default-case, wrap-iife */
-/* global notify */
-/* global notifyPermissions */
-/* global merge_request_widget */
-
-import './smart_interval';
-import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
-
-((global) => {
- var indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; };
-
- const DEPLOYMENT_TEMPLATE = `<div class="mr-widget-heading" id="<%- id %>">
- <div class="ci_widget ci-success">
- <%= ci_success_icon %>
- <span>
- Deployed to
- <a href="<%- url %>" target="_blank" rel="noopener noreferrer" class="environment">
- <%- name %>
- </a>
- <span class="js-environment-timeago" data-toggle="tooltip" data-placement="top" data-title="<%- deployed_at_formatted %>">
- <%- deployed_at %>
- </span>
- <a class="js-environment-link" href="<%- external_url %>" target="_blank" rel="noopener noreferrer">
- <i class="fa fa-external-link"></i>
- View on <%- external_url_formatted %>
- </a>
- </span>
- <span class="stop-env-container js-stop-env-link">
- <a href="<%- stop_url %>" class="close-evn-link" data-method="post" rel="nofollow" data-confirm="Are you sure you want to stop this environment?">
- <i class="fa fa-stop-circle-o"/>
- Stop environment
- </a>
- </span>
- </div>
- </div>`;
-
- global.MergeRequestWidget = (function() {
- function MergeRequestWidget(opts) {
- // Initialize MergeRequestWidget behavior
- //
- // check_enable - Boolean, whether to check automerge status
- // merge_check_url - String, URL to use to check automerge status
- // ci_status_url - String, URL to use to check CI status
- // pipeline_status_url - String, URL to use to get CI status for Favicon
- //
- this.opts = opts;
- this.opts.pipeline_status_url = `${this.opts.pipeline_status_url}.json`;
- this.$widgetBody = $('.mr-widget-body');
- $('#modal_merge_info').modal({
- show: false
- });
- this.clearEventListeners();
- this.addEventListeners();
- this.getCIStatus(false);
- this.retrieveSuccessIcon();
-
- this.initMiniPipelineGraph();
-
- this.ciStatusInterval = new global.SmartInterval({
- callback: this.getCIStatus.bind(this, true),
- startingInterval: 10000,
- maxInterval: 30000,
- hiddenInterval: 120000,
- incrementByFactorOf: 5000,
- });
- this.ciEnvironmentStatusInterval = new global.SmartInterval({
- callback: this.getCIEnvironmentsStatus.bind(this),
- startingInterval: 30000,
- maxInterval: 120000,
- hiddenInterval: 240000,
- incrementByFactorOf: 15000,
- immediateExecution: true,
- });
-
- notifyPermissions();
- }
-
- MergeRequestWidget.prototype.clearEventListeners = function() {
- return $(document).off('DOMContentLoaded');
- };
-
- MergeRequestWidget.prototype.addEventListeners = function() {
- var allowedPages;
- allowedPages = ['show', 'commits', 'pipelines', 'changes'];
- $(document).on('DOMContentLoaded', (function(_this) {
- return function() {
- var page;
- page = $('body').data('page').split(':').last();
- if (allowedPages.indexOf(page) === -1) {
- return _this.clearEventListeners();
- }
- };
- })(this));
- };
-
- MergeRequestWidget.prototype.retrieveSuccessIcon = function() {
- const $ciSuccessIcon = $('.js-success-icon');
- this.$ciSuccessIcon = $ciSuccessIcon.html();
- $ciSuccessIcon.remove();
- };
-
- MergeRequestWidget.prototype.mergeInProgress = function(deleteSourceBranch) {
- if (deleteSourceBranch == null) {
- deleteSourceBranch = false;
- }
- return $.ajax({
- type: 'GET',
- url: $('.merge-request').data('url'),
- success: (function(_this) {
- return function(data) {
- var callback, urlSuffix;
- if (data.state === "merged") {
- urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : '';
- return window.location.href = window.location.pathname + urlSuffix;
- } else if (data.merge_error) {
- return $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>");
- } else {
- callback = function() {
- return merge_request_widget.mergeInProgress(deleteSourceBranch);
- };
- return setTimeout(callback, 2000);
- }
- };
- })(this),
- dataType: 'json'
- });
- };
-
- MergeRequestWidget.prototype.cancelPolling = function () {
- this.ciStatusInterval.cancel();
- this.ciEnvironmentStatusInterval.cancel();
- };
-
- MergeRequestWidget.prototype.getMergeStatus = function() {
- return $.get(this.opts.merge_check_url, (data) => {
- var $html = $(data);
- this.updateMergeButton(this.status, this.hasCi, $html);
- $('.mr-widget-body').replaceWith($html.find('.mr-widget-body'));
- $('.mr-widget-footer').replaceWith($html.find('.mr-widget-footer'));
- });
- };
-
- MergeRequestWidget.prototype.ciLabelForStatus = function(status) {
- switch (status) {
- case 'success':
- return 'passed';
- case 'success_with_warnings':
- return 'passed with warnings';
- default:
- return status;
- }
- };
-
- MergeRequestWidget.prototype.getCIStatus = function(showNotification) {
- var _this;
- _this = this;
- $('.ci-widget-fetching').show();
- return $.getJSON(this.opts.ci_status_url, (function(_this) {
- return function(data) {
- var message, status, title, callback;
- _this.status = data.status;
- _this.hasCi = data.has_ci;
- _this.updateMergeButton(_this.status, _this.hasCi);
- gl.utils.setCiStatusFavicon(_this.opts.pipeline_status_url);
- if (data.environments && data.environments.length) _this.renderEnvironments(data.environments);
- if (data.status !== _this.opts.ci_status ||
- data.sha !== _this.opts.ci_sha ||
- data.pipeline !== _this.opts.ci_pipeline) {
- _this.opts.ci_status = data.status;
- _this.showCIStatus(data.status);
- if (data.coverage) {
- _this.showCICoverage(data.coverage);
- }
- if (data.pipeline) {
- _this.opts.ci_pipeline = data.pipeline;
- _this.updatePipelineUrls(data.pipeline);
- }
- if (data.sha) {
- _this.opts.ci_sha = data.sha;
- _this.updateCommitUrls(data.sha);
- }
- if (data.status === "success" || data.status === "failed") {
- callback = function() {
- return _this.getMergeStatus();
- };
- return setTimeout(callback, 2000);
- }
- if (showNotification && data.status) {
- status = _this.ciLabelForStatus(data.status);
- if (status === "preparing") {
- title = _this.opts.ci_title.preparing;
- status = status.charAt(0).toUpperCase() + status.slice(1);
- message = _this.opts.ci_message.preparing.replace('{{status}}', status);
- } else {
- title = _this.opts.ci_title.normal;
- message = _this.opts.ci_message.normal.replace('{{status}}', status);
- }
- title = title.replace('{{status}}', status);
- message = message.replace('{{sha}}', data.sha);
- message = message.replace('{{title}}', data.title);
- notify(title, message, _this.opts.gitlab_icon, function() {
- this.close();
- });
- }
- }
- };
- })(this));
- };
-
- MergeRequestWidget.prototype.getCIEnvironmentsStatus = function() {
- $.getJSON(this.opts.ci_environments_status_url, (environments) => {
- if (environments && environments.length) this.renderEnvironments(environments);
- });
- };
-
- MergeRequestWidget.prototype.renderEnvironments = function(environments) {
- for (let i = 0; i < environments.length; i += 1) {
- const environment = environments[i];
- if ($(`.mr-state-widget #${environment.id}`).length) return;
- const $template = $(DEPLOYMENT_TEMPLATE);
- if (!environment.external_url || !environment.external_url_formatted) $('.js-environment-link', $template).remove();
-
- if (!environment.stop_url) {
- $('.js-stop-env-link', $template).remove();
- }
-
- if (environment.deployed_at && environment.deployed_at_formatted) {
- environment.deployed_at = gl.utils.getTimeago().format(environment.deployed_at, 'gl_en') + '.';
- } else {
- $('.js-environment-timeago', $template).remove();
- environment.name += '.';
- }
- environment.ci_success_icon = this.$ciSuccessIcon;
- const templateString = _.unescape($template[0].outerHTML);
- const template = _.template(templateString)(environment);
- this.$widgetBody.before(template);
- }
- };
-
- MergeRequestWidget.prototype.showCIStatus = function(state) {
- var allowed_states;
- if (state == null) {
- return;
- }
- $('.ci_widget').hide();
- $('.ci_widget.ci-' + state).show();
-
- this.initMiniPipelineGraph();
- };
-
- MergeRequestWidget.prototype.showCICoverage = function(coverage) {
- var text = `Coverage ${coverage}%`;
- return $('.ci_widget:visible .ci-coverage').text(text);
- };
-
- MergeRequestWidget.prototype.updateMergeButton = function(state, hasCi, $html) {
- const allowed_states = ["failed", "canceled", "running", "pending", "success", "success_with_warnings", "skipped", "not_found"];
- let stateClass = 'btn-danger';
- if (!hasCi) {
- stateClass = 'btn-create';
- } else if (indexOf.call(allowed_states, state) !== -1) {
- switch (state) {
- case "failed":
- case "canceled":
- case "not_found":
- stateClass = 'btn-danger';
- break;
- case "running":
- stateClass = 'btn-info';
- break;
- case "success":
- case "success_with_warnings":
- stateClass = 'btn-create';
- }
- } else {
- $('.ci_widget.ci-error').show();
- stateClass = 'btn-danger';
- }
-
- this.setMergeButtonClass(stateClass, $html);
- };
-
- MergeRequestWidget.prototype.setMergeButtonClass = function(css_class, $html = $('.mr-state-widget')) {
- return $html.find('.js-merge-button').removeClass('btn-danger btn-info btn-create').addClass(css_class);
- };
-
- MergeRequestWidget.prototype.updatePipelineUrls = function(id) {
- const pipelineUrl = this.opts.pipeline_path;
- $('.pipeline').text(`#${id}`).attr('href', [pipelineUrl, id].join('/'));
- };
-
- MergeRequestWidget.prototype.updateCommitUrls = function(id) {
- const commitsUrl = this.opts.commits_path;
- $('.js-commit-link').text(id).attr('href', [commitsUrl, id].join('/'));
- };
-
- MergeRequestWidget.prototype.initMiniPipelineGraph = function() {
- new MiniPipelineGraph({
- container: '.js-pipeline-inline-mr-widget-graph:visible',
- }).bindEvents();
- };
-
- return MergeRequestWidget;
- })();
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index 11e68c0a3be..9d481d7c003 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -18,12 +18,11 @@
}
$els.each(function(i, dropdown) {
- var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, showAny, showNo, showUpcoming, showStarted, useId, showMenuAbove;
+ var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, defaultNo, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, selectedMilestoneDefault, showAny, showNo, showUpcoming, showStarted, useId, showMenuAbove;
$dropdown = $(dropdown);
projectId = $dropdown.data('project-id');
milestonesUrl = $dropdown.data('milestones');
issueUpdateURL = $dropdown.data('issueUpdate');
- selectedMilestone = $dropdown.data('selected');
showNo = $dropdown.data('show-no');
showAny = $dropdown.data('show-any');
showMenuAbove = $dropdown.data('showMenuAbove');
@@ -31,6 +30,7 @@
showStarted = $dropdown.data('show-started');
useId = $dropdown.data('use-id');
defaultLabel = $dropdown.data('default-label');
+ defaultNo = $dropdown.data('default-no');
issuableId = $dropdown.data('issuable-id');
abilityName = $dropdown.data('ability-name');
$selectbox = $dropdown.closest('.selectbox');
@@ -38,6 +38,9 @@
$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon');
$value = $block.find('.value');
$loading = $block.find('.block-loading').fadeOut();
+ selectedMilestoneDefault = (showAny ? '' : null);
+ selectedMilestoneDefault = (showNo && defaultNo ? 'No Milestone' : selectedMilestoneDefault);
+ selectedMilestone = $dropdown.data('selected') || selectedMilestoneDefault;
if (issueUpdateURL) {
milestoneLinkTemplate = _.template('<a href="/<%- full_path %>/milestones/<%- iid %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>');
milestoneLinkNoneTemplate = '<span class="no-value">None</span>';
@@ -86,8 +89,18 @@
if (showMenuAbove) {
$dropdown.data('glDropdown').positionMenuAbove();
}
+ $(`[data-milestone-id="${selectedMilestone}"] > a`).addClass('is-active');
});
},
+ renderRow: function(milestone) {
+ return `
+ <li data-milestone-id="${milestone.name}">
+ <a href='#' class='dropdown-menu-milestone-link'>
+ ${_.escape(milestone.title)}
+ </a>
+ </li>
+ `;
+ },
filterable: true,
search: {
fields: ['title']
@@ -120,15 +133,24 @@
// display:block overrides the hide-collapse rule
return $value.css('display', '');
},
+ opened: function(e) {
+ const $el = $(e.currentTarget);
+ if ($dropdown.hasClass('js-issue-board-sidebar')) {
+ selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault;
+ }
+ $('a.is-active', $el).removeClass('is-active');
+ $(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active');
+ },
vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: function(options) {
const { $el, e } = options;
let selected = options.selectedObj;
-
- var data, isIssueIndex, isMRIndex, page, boardsStore;
+ var data, isIssueIndex, isMRIndex, isSelecting, page, boardsStore;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = (page === page && page === 'projects:merge_requests:index');
+ isSelecting = (selected.name !== selectedMilestone);
+ selectedMilestone = isSelecting ? selected.name : selectedMilestoneDefault;
if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) {
e.preventDefault();
return;
@@ -142,16 +164,11 @@
boardsStore[$dropdown.data('field-name')] = selected.name;
e.preventDefault();
} else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
- if (selected.name != null) {
- selectedMilestone = selected.name;
- } else {
- selectedMilestone = '';
- }
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 (selected.id !== -1) {
+ if (selected.id !== -1 && isSelecting) {
gl.issueBoards.boardStoreIssueSet('milestone', new ListMilestone({
id: selected.id,
title: selected.name
diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js
index 36bc1257cef..5da2db063a4 100644
--- a/app/assets/javascripts/namespace_select.js
+++ b/app/assets/javascripts/namespace_select.js
@@ -1,12 +1,10 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, vars-on-top, one-var-declaration-per-line, comma-dangle, object-shorthand, no-else-return, prefer-template, quotes, prefer-arrow-callback, no-param-reassign, no-cond-assign, max-len */
-/* global Api */
+import Api from './api';
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
window.NamespaceSelect = (function() {
function NamespaceSelect(opts) {
- this.onSelectItem = bind(this.onSelectItem, this);
+ this.onSelectItem = this.onSelectItem.bind(this);
var fieldName, showAny;
this.dropdown = opts.dropdown;
showAny = true;
diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js
index 4903875dfa0..39fb302b644 100644
--- a/app/assets/javascripts/new_branch_form.js
+++ b/app/assets/javascripts/new_branch_form.js
@@ -2,12 +2,9 @@
import RefSelectDropdown from '~/ref_select_dropdown';
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
- indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; };
-
this.NewBranchForm = (function() {
function NewBranchForm(form, availableRefs) {
- this.validate = bind(this.validate, this);
+ this.validate = this.validate.bind(this);
this.branchNameError = form.find('.js-branch-name-error');
this.name = form.find('.js-branch-name');
this.ref = form.find('#ref');
@@ -54,6 +51,8 @@ import RefSelectDropdown from '~/ref_select_dropdown';
NewBranchForm.prototype.validate = function() {
var errorMessage, errors, formatter, unique, validator;
+ const indexOf = [].indexOf;
+
this.branchNameError.empty();
unique = function(values, value) {
if (indexOf.call(values, value) === -1) {
diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js
index ad36f08840d..658879607e2 100644
--- a/app/assets/javascripts/new_commit_form.js
+++ b/app/assets/javascripts/new_commit_form.js
@@ -1,12 +1,10 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-return-assign, max-len */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.NewCommitForm = (function() {
function NewCommitForm(form, targetBranchName = 'target_branch') {
this.form = form;
this.targetBranchName = targetBranchName;
- this.renderDestination = bind(this.renderDestination, this);
+ this.renderDestination = this.renderDestination.bind(this);
this.targetBranchDropdown = form.find('button.js-target-branch');
this.originalBranch = form.find('.js-original-branch');
this.createMergeRequest = form.find('.js-create-merge-request');
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 3e8240d10ec..814d2ea92b4 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -30,7 +30,7 @@
|
\\s\\$(?!\\$)
)
- (.+?)
+ ((.|\\n)+?)
(
\\s\\\\end{[a-zA-Z]+}$
|
@@ -45,15 +45,25 @@
let inline = false;
if (typeof katex !== 'undefined') {
- const katexString = text.replace(/\\/g, '\\');
- const matches = new RegExp(katexRegexString, 'gi').exec(katexString);
+ const katexString = text.replace(/&amp;/g, '&')
+ .replace(/&=&/g, '\\space=\\space')
+ .replace(/<(\/?)em>/g, '_');
+ const regex = new RegExp(katexRegexString, 'gi');
+ const matchLocation = katexString.search(regex);
+ const numberOfMatches = katexString.match(regex);
- if (matches && matches.length > 0) {
- if (matches[1].trim() === '$' && matches[3].trim() === '$') {
+ if (numberOfMatches && numberOfMatches.length !== 0) {
+ if (matchLocation > 0) {
+ let matches = regex.exec(katexString);
inline = true;
- text = `${katexString.replace(matches[0], '')} ${katex.renderToString(matches[2])}`;
+ while (matches !== null) {
+ const renderedKatex = katex.renderToString(matches[0].replace(/\$/g, ''));
+ text = `${text.replace(matches[0], ` ${renderedKatex}`)}`;
+ matches = regex.exec(katexString);
+ }
} else {
+ const matches = regex.exec(katexString);
text = katex.renderToString(matches[2]);
}
}
@@ -79,7 +89,7 @@
},
computed: {
markdown() {
- return marked(this.cell.source.join(''));
+ return marked(this.cell.source.join('').replace(/\\/g, '\\\\'));
},
},
};
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 8ff25c10157..8bdbbe6612f 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -1,4 +1,10 @@
-/* eslint-disable no-restricted-properties, func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, camelcase, no-unused-expressions, quotes, max-len, one-var, one-var-declaration-per-line, default-case, prefer-template, consistent-return, no-alert, no-return-assign, no-param-reassign, prefer-arrow-callback, no-else-return, comma-dangle, no-new, brace-style, no-lonely-if, vars-on-top, no-unused-vars, no-sequences, no-shadow, newline-per-chained-call, no-useless-escape */
+/* eslint-disable no-restricted-properties, func-names, space-before-function-paren,
+no-var, prefer-rest-params, wrap-iife, no-use-before-define, camelcase,
+no-unused-expressions, quotes, max-len, one-var, one-var-declaration-per-line,
+default-case, prefer-template, consistent-return, no-alert, no-return-assign,
+no-param-reassign, prefer-arrow-callback, no-else-return, comma-dangle, no-new,
+brace-style, no-lonely-if, vars-on-top, no-unused-vars, no-sequences, no-shadow,
+newline-per-chained-call, no-useless-escape */
/* global Flash */
/* global Autosave */
/* global ResolveService */
@@ -6,31 +12,31 @@
import $ from 'jquery';
import Cookies from 'js-cookie';
+import autosize from 'vendor/autosize';
+import Dropzone from 'dropzone';
+import 'vendor/jquery.caret'; // required by jquery.atwho
+import 'vendor/jquery.atwho';
+import AjaxCache from '~/lib/utils/ajax_cache';
import CommentTypeToggle from './comment_type_toggle';
+import './autosave';
+import './dropzone_input';
+import './task_list';
-require('./autosave');
-window.autosize = require('vendor/autosize');
-window.Dropzone = require('dropzone');
-require('./dropzone_input');
-require('./gfm_auto_complete');
-require('vendor/jquery.caret'); // required by jquery.atwho
-require('vendor/jquery.atwho');
-require('./task_list');
+window.autosize = autosize;
+window.Dropzone = Dropzone;
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+.*$/gm;
Notes.interval = null;
- function Notes(notes_url, note_ids, last_fetched_at, view) {
+ function Notes(notes_url, note_ids, last_fetched_at, view, enableGFM = true) {
this.updateTargetButtons = this.updateTargetButtons.bind(this);
this.updateComment = this.updateComment.bind(this);
this.visibilityChange = this.visibilityChange.bind(this);
@@ -49,14 +55,16 @@ const normalizeNewlines = function(str) {
this.keydownNoteText = this.keydownNoteText.bind(this);
this.toggleCommitList = this.toggleCommitList.bind(this);
this.postComment = this.postComment.bind(this);
+ this.clearFlashWrapper = this.clearFlash.bind(this);
this.notes_url = notes_url;
this.note_ids = note_ids;
+ this.enableGFM = enableGFM;
// 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.notesCountBadge || (this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'));
this.basePollingInterval = 15000;
this.maxPollingSteps = 4;
@@ -85,61 +93,61 @@ const normalizeNewlines = function(str) {
Notes.prototype.addBinding = function() {
// Edit note link
- $(document).on("click", ".js-note-edit", this.showEditForm.bind(this));
- $(document).on("click", ".note-edit-cancel", this.cancelEdit);
+ $(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-submit-button", this.postComment);
- $(document).on("click", ".js-comment-save-button", this.updateComment);
- $(document).on("keyup input", ".js-note-text", this.updateTargetButtons);
+ $(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.postComment);
// remove a note (in general)
- $(document).on("click", ".js-note-delete", this.removeNote);
+ $(document).on('click', '.js-note-delete', this.removeNote);
// delete note attachment
- $(document).on("click", ".js-note-attachment-delete", this.removeAttachment);
+ $(document).on('click', '.js-note-attachment-delete', this.removeAttachment);
// reset main target form when clicking discard
- $(document).on("click", ".js-note-discard", this.resetMainTargetForm);
+ $(document).on('click', '.js-note-discard', this.resetMainTargetForm);
// update the file name when an attachment is selected
- $(document).on("change", ".js-note-attachment-input", this.updateFormAttachment);
+ $(document).on('change', '.js-note-attachment-input', this.updateFormAttachment);
// reply to diff/discussion notes
- $(document).on("click", ".js-discussion-reply-button", this.onReplyToDiscussionNote);
+ $(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote);
// add diff note
- $(document).on("click", ".js-add-diff-note-button", this.onAddDiffNote);
+ $(document).on('click', '.js-add-diff-note-button', this.onAddDiffNote);
// hide diff note form
- $(document).on("click", ".js-close-discussion-note-form", this.cancelDiscussionForm);
+ $(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm);
// toggle commit list
- $(document).on("click", '.system-note-commit-list-toggler', this.toggleCommitList);
+ $(document).on('click', '.system-note-commit-list-toggler', this.toggleCommitList);
// fetch notes when tab becomes visible
- $(document).on("visibilitychange", this.visibilityChange);
+ $(document).on('visibilitychange', this.visibilityChange);
// when issue status changes, we need to refresh data
- $(document).on("issuable:change", this.refresh);
+ $(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);
+ $(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);
+ return $(document).on('keydown', '.js-note-text', this.keydownNoteText);
};
Notes.prototype.cleanBinding = function() {
- $(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("click", ".js-discussion-reply-button");
- $(document).off("click", ".js-add-diff-note-button");
- $(document).off("visibilitychange");
- $(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-note-edit');
+ $(document).off('click', '.note-edit-cancel');
+ $(document).off('click', '.js-note-delete');
+ $(document).off('click', '.js-note-attachment-delete');
+ $(document).off('click', '.js-discussion-reply-button');
+ $(document).off('click', '.js-add-diff-note-button');
+ $(document).off('visibilitychange');
+ $(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");
+ $(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) {
@@ -175,7 +183,7 @@ const normalizeNewlines = function(str) {
if ($textarea.val() !== '') {
return;
}
- myLastNote = $("li.note[data-author-id='" + gon.current_user_id + "'][data-editable]:last");
+ myLastNote = $(`li.note[data-author-id='${gon.current_user_id}'][data-editable]:last`, $textarea.closest('.note, #notes'));
if (myLastNote.length) {
myLastNoteEditBtn = myLastNote.find('.js-note-edit');
return myLastNoteEditBtn.trigger('click', [true, myLastNote]);
@@ -229,8 +237,8 @@ const normalizeNewlines = function(str) {
this.refreshing = true;
return $.ajax({
url: this.notes_url,
- headers: { "X-Last-Fetched-At": this.last_fetched_at },
- dataType: "json",
+ headers: { 'X-Last-Fetched-At': this.last_fetched_at },
+ dataType: 'json',
success: (function(_this) {
return function(data) {
var notes;
@@ -301,13 +309,13 @@ const normalizeNewlines = function(str) {
*/
Notes.prototype.renderNote = function(noteEntity, $form, $notesList = $('.main-notes-list')) {
- if (noteEntity.discussion_html != null) {
+ if (noteEntity.discussion_html) {
return this.renderDiscussionNote(noteEntity, $form);
}
if (!noteEntity.valid) {
if (noteEntity.errors.commands_only) {
- new Flash(noteEntity.errors.commands_only, 'notice', this.parentTimeline);
+ this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline);
this.refresh();
}
return;
@@ -317,6 +325,9 @@ const normalizeNewlines = function(str) {
if (Notes.isNewNote(noteEntity, this.note_ids)) {
this.note_ids.push(noteEntity.id);
+ if ($notesList.length) {
+ $notesList.find('.system-note.being-posted').remove();
+ }
const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList);
this.setupNewNote($newNote);
@@ -366,8 +377,8 @@ const normalizeNewlines = function(str) {
return;
}
this.note_ids.push(noteEntity.id);
- form = $form || $(".js-discussion-note-form[data-discussion-id='" + noteEntity.discussion_id + "']");
- row = form.closest("tr");
+ 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?
@@ -384,7 +395,7 @@ const normalizeNewlines = function(str) {
row.after($discussion);
} else {
// Merge new discussion HTML in
- var $notes = $discussion.find('.notes[data-discussion-id="' + noteEntity.discussion_id + '"]');
+ var $notes = $discussion.find(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`);
var contentContainerClass = '.' + $notes.closest('.notes_content')
.attr('class')
.split(' ')
@@ -395,7 +406,7 @@ const normalizeNewlines = function(str) {
}
// Init discussion on 'Discussion' page if it is merge request page
const page = $('body').attr('data-page');
- if ((page && page.indexOf('projects:merge_request') === 0) || !noteEntity.diff_discussion_html) {
+ if ((page && page.indexOf('projects:merge_request') !== -1) || !noteEntity.diff_discussion_html) {
Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list'));
}
} else {
@@ -448,13 +459,13 @@ const normalizeNewlines = function(str) {
Notes.prototype.resetMainTargetForm = function(e) {
var form;
- form = $(".js-main-target-form");
+ form = $('.js-main-target-form');
// remove validation errors
- form.find(".js-errors").remove();
+ form.find('.js-errors').remove();
// reset text and preview
- form.find(".js-md-write-button").click();
- form.find(".js-note-text").val("").trigger("input");
- form.find(".js-note-text").data("autosave").reset();
+ form.find('.js-md-write-button').click();
+ form.find('.js-note-text').val('').trigger('input');
+ form.find('.js-note-text').data('autosave').reset();
var event = document.createEvent('Event');
event.initEvent('autosize:update', true, false);
@@ -465,8 +476,8 @@ const normalizeNewlines = function(str) {
Notes.prototype.reenableTargetFormSubmitButton = function() {
var form;
- form = $(".js-main-target-form");
- return form.find(".js-note-text").trigger("input");
+ form = $('.js-main-target-form');
+ return form.find('.js-note-text').trigger('input');
};
/*
@@ -478,18 +489,18 @@ const normalizeNewlines = function(str) {
Notes.prototype.setupMainTargetNoteForm = function() {
var form;
// find the form
- form = $(".js-new-note-form");
+ form = $('.js-new-note-form');
// Set a global clone of the form for later cloning
this.formClone = form.clone();
// show the form
this.setupNoteForm(form);
// fix classes
- form.removeClass("js-new-note-form");
- form.addClass("js-main-target-form");
- form.find("#note_line_code").remove();
- form.find("#note_position").remove();
- form.find("#note_type").val('');
- form.find("#in_reply_to_discussion_id").remove();
+ form.removeClass('js-new-note-form');
+ form.addClass('js-main-target-form');
+ form.find('#note_line_code').remove();
+ form.find('#note_position').remove();
+ form.find('#note_type').val('');
+ form.find('#in_reply_to_discussion_id').remove();
form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove();
this.parentTimeline = form.parents('.timeline');
@@ -509,21 +520,21 @@ const normalizeNewlines = function(str) {
Notes.prototype.setupNoteForm = function(form) {
var textarea, key;
- new gl.GLForm(form);
- textarea = form.find(".js-note-text");
+ new gl.GLForm(form, this.enableGFM);
+ textarea = form.find('.js-note-text');
key = [
- "Note",
- form.find("#note_noteable_type").val(),
- form.find("#note_noteable_id").val(),
- form.find("#note_commit_id").val(),
- form.find("#note_type").val(),
- form.find("#in_reply_to_discussion_id").val(),
+ 'Note',
+ form.find('#note_noteable_type').val(),
+ form.find('#note_noteable_id').val(),
+ form.find('#note_commit_id').val(),
+ form.find('#note_type').val(),
+ form.find('#in_reply_to_discussion_id').val(),
// LegacyDiffNote
- form.find("#note_line_code").val(),
+ form.find('#note_line_code').val(),
// DiffNote
- form.find("#note_position").val()
+ form.find('#note_position').val()
];
return new Autosave(textarea, key);
};
@@ -538,14 +549,14 @@ const normalizeNewlines = function(str) {
return this.renderNote(note);
};
- Notes.prototype.addNoteError = ($form) => {
+ Notes.prototype.addNoteError = function($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);
+ return this.addFlash('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.');
@@ -668,7 +679,8 @@ const normalizeNewlines = function(str) {
const $newNote = $(this.updatedNotesTrackingMap[noteId].html);
$note.replaceWith($newNote);
this.setupNewNote($newNote);
- this.updatedNotesTrackingMap[noteId] = null;
+ // Now that we have taken care of the update, clear it out
+ delete this.updatedNotesTrackingMap[noteId];
}
else {
$note.find('.js-finish-edit-warning').hide();
@@ -720,14 +732,14 @@ const normalizeNewlines = function(str) {
lineHolder = $(e.currentTarget).closest('.notes[data-discussion-id]')
.closest('.notes_holder')
.prev('.line_holder');
- $(".note[id='" + noteElId + "']").each((function(_this) {
+ $(`.note[id="${noteElId}"]`).each((function(_this) {
// A same note appears in the "Discussion" and in the "Changes" tab, we have
- // to remove all. Using $(".note[id='noteId']") ensure we get all the notes,
- // where $("#noteId") would return only one.
+ // 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");
+ $notes = $note.closest('.discussion-notes');
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
if (gl.diffNoteApps[noteElId]) {
@@ -738,11 +750,11 @@ const normalizeNewlines = function(str) {
$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) {
@@ -766,11 +778,11 @@ const normalizeNewlines = function(str) {
*/
Notes.prototype.removeAttachment = function() {
- 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();
+ 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();
};
/*
@@ -786,7 +798,7 @@ const normalizeNewlines = function(str) {
Notes.prototype.replyToDiscussionNote = function(target) {
var form, replyLink;
form = this.cleanForm(this.formClone.clone());
- replyLink = $(target).closest(".js-discussion-reply-button");
+ replyLink = $(target).closest('.js-discussion-reply-button');
// insert the form after the button
replyLink
.closest('.discussion-reply-holder')
@@ -806,26 +818,26 @@ const normalizeNewlines = function(str) {
Notes.prototype.setupDiscussionNoteForm = function(dataHolder, form) {
// setup note target
- var discussionID = dataHolder.data("discussionId");
+ var discussionID = dataHolder.data('discussionId');
if (discussionID) {
- form.attr("data-discussion-id", discussionID);
- form.find("#in_reply_to_discussion_id").val(discussionID);
+ form.attr('data-discussion-id', discussionID);
+ form.find('#in_reply_to_discussion_id').val(discussionID);
}
- form.attr("data-line-code", dataHolder.data("lineCode"));
- form.find("#line_type").val(dataHolder.data("lineType"));
+ form.attr('data-line-code', dataHolder.data('lineCode'));
+ form.find('#line_type').val(dataHolder.data('lineType'));
- form.find("#note_noteable_type").val(dataHolder.data("noteableType"));
- form.find("#note_noteable_id").val(dataHolder.data("noteableId"));
- form.find("#note_commit_id").val(dataHolder.data("commitId"));
- form.find("#note_type").val(dataHolder.data("noteType"));
+ form.find('#note_noteable_type').val(dataHolder.data('noteableType'));
+ form.find('#note_noteable_id').val(dataHolder.data('noteableId'));
+ form.find('#note_commit_id').val(dataHolder.data('commitId'));
+ form.find('#note_type').val(dataHolder.data('noteType'));
// LegacyDiffNote
- form.find("#note_line_code").val(dataHolder.data("lineCode"));
+ form.find('#note_line_code').val(dataHolder.data('lineCode'));
// DiffNote
- form.find("#note_position").val(dataHolder.attr("data-position"));
+ form.find('#note_position').val(dataHolder.attr('data-position'));
form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text'));
form.find('.js-note-target-close').remove();
@@ -834,7 +846,7 @@ const normalizeNewlines = function(str) {
form
.removeClass('js-main-target-form')
- .addClass("discussion-form js-discussion-note-form");
+ .addClass('discussion-form js-discussion-note-form');
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
var $commentBtn = form.find('comment-and-resolve-btn');
@@ -843,7 +855,7 @@ const normalizeNewlines = function(str) {
gl.diffNotesCompileComponents();
}
- form.find(".js-note-text").focus();
+ form.find('.js-note-text').focus();
form
.find('.js-comment-resolve-button')
.attr('data-discussion-id', discussionID);
@@ -876,21 +888,21 @@ const normalizeNewlines = function(str) {
}) {
var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar;
$link = $(target);
- row = $link.closest("tr");
+ row = $link.closest('tr');
const nextRow = row.next();
let targetRow = row;
if (nextRow.is('.notes_holder')) {
targetRow = nextRow;
}
- hasNotes = targetRow.is(".notes_holder");
+ hasNotes = nextRow.is('.notes_holder');
addForm = false;
let lineTypeSelector = '';
- rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"><div class=\"content\"></div></td></tr>";
+ rowCssToAdd = '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line" colspan="2"></td><td class="notes_content"><div class="content"></div></td></tr>';
// In parallel view, look inside the correct left/right pane
if (this.isParallelView()) {
lineTypeSelector = `.${lineType}`;
- rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line old\"></td><td class=\"notes_content parallel old\"><div class=\"content\"></div></td><td class=\"notes_line new\"></td><td class=\"notes_content parallel new\"><div class=\"content\"></div></td></tr>";
+ rowCssToAdd = '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content"></div></td></tr>';
}
const notesContentSelector = `.notes_content${lineTypeSelector} .content`;
let notesContent = targetRow.find(notesContentSelector);
@@ -900,12 +912,12 @@ const normalizeNewlines = function(str) {
notesContent = targetRow.find(notesContentSelector);
if (notesContent.length) {
notesContent.show();
- replyButton = notesContent.find(".js-discussion-reply-button:visible");
+ replyButton = notesContent.find('.js-discussion-reply-button:visible');
if (replyButton.length) {
this.replyToDiscussionNote(replyButton[0]);
} else {
// In parallel view, the form may not be present in one of the panes
- noteForm = notesContent.find(".js-discussion-note-form");
+ noteForm = notesContent.find('.js-discussion-note-form');
if (noteForm.length === 0) {
addForm = true;
}
@@ -943,15 +955,15 @@ const normalizeNewlines = function(str) {
Notes.prototype.removeDiscussionNoteForm = function(form) {
var glForm, row;
- row = form.closest("tr");
+ row = form.closest('tr');
glForm = form.data('gl-form');
glForm.destroy();
- form.find(".js-note-text").data("autosave").reset();
+ form.find('.js-note-text').data('autosave').reset();
// show the reply button (will only work for replies)
form
.prev('.discussion-reply-holder')
.show();
- if (row.is(".js-temp-notes-holder")) {
+ if (row.is('.js-temp-notes-holder')) {
// remove temporary row for diff lines
return row.remove();
} else {
@@ -963,7 +975,7 @@ const normalizeNewlines = function(str) {
Notes.prototype.cancelDiscussionForm = function(e) {
var form;
e.preventDefault();
- form = $(e.target).closest(".js-discussion-note-form");
+ form = $(e.target).closest('.js-discussion-note-form');
return this.removeDiscussionNoteForm(form);
};
@@ -975,10 +987,10 @@ const normalizeNewlines = function(str) {
Notes.prototype.updateFormAttachment = function() {
var filename, form;
- form = $(this).closest("form");
+ form = $(this).closest('form');
// get only the basename
- filename = $(this).val().replace(/^.*[\\\/]/, "");
- return form.find(".js-attachment-filename").text(filename);
+ filename = $(this).val().replace(/^.*[\\\/]/, '');
+ return form.find('.js-attachment-filename').text(filename);
};
/*
@@ -1108,6 +1120,17 @@ const normalizeNewlines = function(str) {
});
};
+ Notes.prototype.addFlash = function(...flashParams) {
+ this.flashInstance = new Flash(...flashParams);
+ };
+
+ Notes.prototype.clearFlash = function() {
+ if (this.flashInstance && this.flashInstance.flashContainer) {
+ this.flashInstance.flashContainer.hide();
+ this.flashInstance = null;
+ }
+ };
+
Notes.prototype.cleanForm = function($form) {
// Remove JS classes that are not needed here
$form
@@ -1169,7 +1192,7 @@ const normalizeNewlines = function(str) {
Notes.prototype.getFormData = function($form) {
return {
formData: $form.serialize(),
- formContent: $form.find('.js-note-text').val(),
+ formContent: _.escape($form.find('.js-note-text').val()),
formAction: $form.attr('action'),
};
};
@@ -1189,19 +1212,47 @@ const normalizeNewlines = function(str) {
};
/**
+ * Gets appropriate description from slash commands found in provided `formContent`
+ */
+ Notes.prototype.getSlashCommandDescription = function (formContent, availableSlashCommands = []) {
+ let tempFormContent;
+
+ // Identify executed slash commands from `formContent`
+ const executedCommands = availableSlashCommands.filter((command, index) => {
+ const commandRegex = new RegExp(`/${command.name}`);
+ return commandRegex.test(formContent);
+ });
+
+ if (executedCommands && executedCommands.length) {
+ if (executedCommands.length > 1) {
+ tempFormContent = 'Applying multiple commands';
+ } else {
+ const commandDescription = executedCommands[0].description.toLowerCase();
+ tempFormContent = `Applying command to ${commandDescription}`;
+ }
+ } else {
+ tempFormContent = 'Applying command';
+ }
+
+ return tempFormContent;
+ };
+
+ /**
* 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 }) {
+ Notes.prototype.createPlaceholderNote = function ({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname, currentUserAvatar }) {
const discussionClass = isDiscussionNote ? 'discussion' : '';
const escapedFormContent = _.escape(formContent);
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>
+ <a href="/${currentUsername}">
+ <img class="avatar s40" src="${currentUserAvatar}">
+ </a>
</div>
<div class="timeline-content ${discussionClass}">
<div class="note-header">
@@ -1226,6 +1277,23 @@ const normalizeNewlines = function(str) {
};
/**
+ * Create Placeholder System Note DOM element populated with slash command description
+ */
+ Notes.prototype.createPlaceholderSystemNote = function ({ formContent, uniqueId }) {
+ const $tempNote = $(
+ `<li id="${uniqueId}" class="note system-note timeline-entry being-posted fade-in-half">
+ <div class="timeline-entry-inner">
+ <div class="timeline-content">
+ <i>${formContent}</i>
+ </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).
*
@@ -1256,7 +1324,9 @@ const normalizeNewlines = function(str) {
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 noteUniqueId;
+ let systemNoteUniqueId;
+ let hasSlashCommands = false;
let $notesContainer;
let tempFormContent;
@@ -1277,16 +1347,28 @@ const normalizeNewlines = function(str) {
tempFormContent = formContent;
if (this.hasSlashCommands(formContent)) {
tempFormContent = this.stripSlashCommands(formContent);
+ hasSlashCommands = true;
}
+ // Show placeholder note
if (tempFormContent) {
- // Show placeholder note
+ noteUniqueId = _.uniqueId('tempNote_');
$notesContainer.append(this.createPlaceholderNote({
formContent: tempFormContent,
- uniqueId,
+ uniqueId: noteUniqueId,
isDiscussionNote,
currentUsername: gon.current_username,
currentUserFullname: gon.current_user_fullname,
+ currentUserAvatar: gon.current_user_avatar_url,
+ }));
+ }
+
+ // Show placeholder system note
+ if (hasSlashCommands) {
+ systemNoteUniqueId = _.uniqueId('tempSystemNote_');
+ $notesContainer.append(this.createPlaceholderSystemNote({
+ formContent: this.getSlashCommandDescription(formContent, AjaxCache.get(gl.GfmAutoComplete.dataSources.commands)),
+ uniqueId: systemNoteUniqueId,
}));
}
@@ -1304,7 +1386,15 @@ const normalizeNewlines = function(str) {
gl.utils.ajaxPost(formAction, formData)
.then((note) => {
// Submission successful! remove placeholder
- $notesContainer.find(`#${uniqueId}`).remove();
+ $notesContainer.find(`#${noteUniqueId}`).remove();
+
+ // Reset cached commands list when command is applied
+ if (hasSlashCommands) {
+ $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho');
+ }
+
+ // Clear previous form errors
+ this.clearFlashWrapper();
// Check if this was discussion comment
if (isDiscussionForm) {
@@ -1339,7 +1429,11 @@ const normalizeNewlines = function(str) {
$form.trigger('ajax:success', [note]);
}).fail(() => {
// Submission failed, remove placeholder note and show Flash error message
- $notesContainer.find(`#${uniqueId}`).remove();
+ $notesContainer.find(`#${noteUniqueId}`).remove();
+
+ if (hasSlashCommands) {
+ $notesContainer.find(`#${systemNoteUniqueId}`).remove();
+ }
// Show form again on UI on failure
if (isDiscussionForm && $notesContainer.length) {
diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js
index 5005af90d48..2ab9c4fed2c 100644
--- a/app/assets/javascripts/notifications_form.js
+++ b/app/assets/javascripts/notifications_form.js
@@ -1,10 +1,8 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, newline-per-chained-call, comma-dangle, consistent-return, prefer-arrow-callback, max-len */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.NotificationsForm = (function() {
function NotificationsForm() {
- this.toggleCheckbox = bind(this.toggleCheckbox, this);
+ this.toggleCheckbox = this.toggleCheckbox.bind(this);
this.removeEventListeners();
this.initEventListeners();
}
diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js
index 5f6bc902cf8..01110420cca 100644
--- a/app/assets/javascripts/pager.js
+++ b/app/assets/javascripts/pager.js
@@ -1,16 +1,17 @@
-require('~/lib/utils/common_utils');
-require('~/lib/utils/url_utility');
+import '~/lib/utils/common_utils';
+import '~/lib/utils/url_utility';
(() => {
const ENDLESS_SCROLL_BOTTOM_PX = 400;
const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000;
const Pager = {
- init(limit = 0, preload = false, disable = false, callback = $.noop) {
+ init(limit = 0, preload = false, disable = false, prepareData = $.noop, callback = $.noop) {
this.url = $('.content_list').data('href') || gl.utils.removeParams(['limit', 'offset']);
this.limit = limit;
this.offset = parseInt(gl.utils.getParameterByName('offset'), 10) || this.limit;
this.disable = disable;
+ this.prepareData = prepareData;
this.callback = callback;
this.loading = $('.loading').first();
if (preload) {
@@ -29,7 +30,7 @@ require('~/lib/utils/url_utility');
dataType: 'json',
error: () => this.loading.hide(),
success: (data) => {
- this.append(data.count, data.html);
+ this.append(data.count, this.prepareData(data.html));
this.callback();
// keep loading until we've filled the viewport height
diff --git a/app/assets/javascripts/pipelines/components/async_button.vue b/app/assets/javascripts/pipelines/components/async_button.vue
index d1c60b570de..37a6f02d8fd 100644
--- a/app/assets/javascripts/pipelines/components/async_button.vue
+++ b/app/assets/javascripts/pipelines/components/async_button.vue
@@ -3,6 +3,7 @@
/* global Flash */
import '~/flash';
import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
props: {
@@ -37,6 +38,10 @@ export default {
},
},
+ components: {
+ loadingIcon,
+ },
+
data() {
return {
isLoading: false,
@@ -94,9 +99,6 @@ export default {
<i
:class="iconClass"
aria-hidden="true" />
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true"
- v-if="isLoading" />
+ <loading-icon v-if="isLoading" />
</button>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 1f1b99ff401..61cd623dd00 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -1,68 +1,52 @@
<script>
- /* global Flash */
- import Visibility from 'visibilityjs';
- import Poll from '../../../lib/utils/poll';
- import PipelineService from '../../services/pipeline_service';
- import PipelineStore from '../../stores/pipeline_store';
import stageColumnComponent from './stage_column_component.vue';
+ import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
import '../../../flash';
export default {
- components: {
- stageColumnComponent,
+ props: {
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ pipeline: {
+ type: Object,
+ required: true,
+ },
},
- data() {
- const DOMdata = document.getElementById('js-pipeline-graph-vue').dataset;
- const store = new PipelineStore();
-
- return {
- isLoading: false,
- endpoint: DOMdata.endpoint,
- store,
- state: store.state,
- };
+ components: {
+ stageColumnComponent,
+ loadingIcon,
},
- created() {
- this.service = new PipelineService(this.endpoint);
-
- const poll = new Poll({
- resource: this.service,
- method: 'getPipeline',
- successCallback: this.successCallback,
- errorCallback: this.errorCallback,
- });
-
- if (!Visibility.hidden()) {
- this.isLoading = true;
- poll.makeRequest();
- }
-
- Visibility.change(() => {
- if (!Visibility.hidden()) {
- poll.restart();
- } else {
- poll.stop();
- }
- });
+ computed: {
+ graph() {
+ return this.pipeline.details && this.pipeline.details.stages;
+ },
},
methods: {
- successCallback(response) {
- const data = response.json();
-
- this.isLoading = false;
- this.store.storeGraph(data.details.stages);
+ capitalizeStageName(name) {
+ return name.charAt(0).toUpperCase() + name.slice(1);
},
- errorCallback() {
- this.isLoading = false;
- return new Flash('An error occurred while fetching the pipeline.');
+ isFirstColumn(index) {
+ return index === 0;
},
- capitalizeStageName(name) {
- return name.charAt(0).toUpperCase() + name.slice(1);
+ stageConnectorClass(index, stage) {
+ let className;
+
+ // If it's the first stage column and only has one job
+ if (index === 0 && stage.groups.length === 1) {
+ className = 'no-margin';
+ } else if (index > 0) {
+ // If it is not the first column
+ className = 'left-margin';
+ }
+
+ return className;
},
isFirstColumn(index) {
@@ -89,18 +73,17 @@
<div class="build-content middle-block js-pipeline-graph">
<div class="pipeline-visualization pipeline-graph">
<div class="text-center">
- <i
+ <loading-icon
v-if="isLoading"
- class="loading-icon fa fa-spin fa-spinner fa-3x"
- aria-label="Loading"
- aria-hidden="true" />
+ size="3"
+ />
</div>
<ul
v-if="!isLoading"
class="stage-column-list">
<stage-column-component
- v-for="(stage, index) in state.graph"
+ v-for="(stage, index) in graph"
:title="capitalizeStageName(stage.name)"
:jobs="stage.groups"
:key="stage.name"
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
new file mode 100644
index 00000000000..4f6c5c177cf
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -0,0 +1,97 @@
+<script>
+import ciHeader from '../../vue_shared/components/header_ci_component.vue';
+import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+
+export default {
+ name: 'PipelineHeaderSection',
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ components: {
+ ciHeader,
+ loadingIcon,
+ },
+
+ data() {
+ return {
+ actions: this.getActions(),
+ };
+ },
+
+ computed: {
+ status() {
+ return this.pipeline.details && this.pipeline.details.status;
+ },
+ shouldRenderContent() {
+ return !this.isLoading && Object.keys(this.pipeline).length;
+ },
+ },
+
+ methods: {
+ postAction(action) {
+ const index = this.actions.indexOf(action);
+
+ this.$set(this.actions[index], 'isLoading', true);
+
+ eventHub.$emit('headerPostAction', action);
+ },
+
+ getActions() {
+ const actions = [];
+
+ if (this.pipeline.retry_path) {
+ actions.push({
+ label: 'Retry',
+ path: this.pipeline.retry_path,
+ cssClass: 'js-retry-button btn btn-inverted-secondary',
+ type: 'button',
+ isLoading: false,
+ });
+ }
+
+ if (this.pipeline.cancel_path) {
+ actions.push({
+ label: 'Cancel running',
+ path: this.pipeline.cancel_path,
+ cssClass: 'js-btn-cancel-pipeline btn btn-danger',
+ type: 'button',
+ isLoading: false,
+ });
+ }
+
+ return actions;
+ },
+ },
+
+ watch: {
+ pipeline() {
+ this.actions = this.getActions();
+ },
+ },
+};
+</script>
+<template>
+ <div class="pipeline-header-container">
+ <ci-header
+ v-if="shouldRenderContent"
+ :status="status"
+ item-name="Pipeline"
+ :item-id="pipeline.id"
+ :time="pipeline.created_at"
+ :user="pipeline.user"
+ :actions="actions"
+ @actionClicked="postAction"
+ />
+ <loading-icon
+ v-else
+ size="2"/>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.js b/app/assets/javascripts/pipelines/components/pipeline_url.js
deleted file mode 100644
index 4e183d5c8ec..00000000000
--- a/app/assets/javascripts/pipelines/components/pipeline_url.js
+++ /dev/null
@@ -1,56 +0,0 @@
-export default {
- props: [
- 'pipeline',
- ],
- computed: {
- user() {
- return !!this.pipeline.user;
- },
- },
- template: `
- <td>
- <a
- :href="pipeline.path"
- class="js-pipeline-url-link">
- <span class="pipeline-id">#{{pipeline.id}}</span>
- </a>
- <span>by</span>
- <a
- class="js-pipeline-url-user"
- v-if="user"
- :href="pipeline.user.web_url">
- <img
- v-if="user"
- class="avatar has-tooltip s20 "
- :title="pipeline.user.name"
- data-container="body"
- :src="pipeline.user.avatar_url"
- >
- </a>
- <span
- v-if="!user"
- class="js-pipeline-url-api api monospace">
- API
- </span>
- <span
- v-if="pipeline.flags.latest"
- class="js-pipeline-url-lastest label label-success has-tooltip"
- title="Latest pipeline for this branch"
- data-original-title="Latest pipeline for this branch">
- latest
- </span>
- <span
- v-if="pipeline.flags.yaml_errors"
- class="js-pipeline-url-yaml label label-danger has-tooltip"
- :title="pipeline.yaml_errors"
- :data-original-title="pipeline.yaml_errors">
- yaml invalid
- </span>
- <span
- v-if="pipeline.flags.stuck"
- class="js-pipeline-url-stuck label label-warning">
- stuck
- </span>
- </td>
- `,
-};
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue
new file mode 100644
index 00000000000..4781a8ff1da
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue
@@ -0,0 +1,65 @@
+<script>
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import tooltipMixin from '../../vue_shared/mixins/tooltip';
+
+export default {
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ },
+ components: {
+ userAvatarLink,
+ },
+ mixins: [
+ tooltipMixin,
+ ],
+ computed: {
+ user() {
+ return this.pipeline.user;
+ },
+ },
+};
+</script>
+<template>
+ <td>
+ <a
+ :href="pipeline.path"
+ class="js-pipeline-url-link">
+ <span class="pipeline-id">#{{pipeline.id}}</span>
+ </a>
+ <span>by</span>
+ <user-avatar-link
+ v-if="user"
+ class="js-pipeline-url-user"
+ :link-href="pipeline.user.path"
+ :img-src="pipeline.user.avatar_url"
+ :tooltip-text="pipeline.user.name"
+ />
+ <span
+ v-if="!user"
+ class="js-pipeline-url-api api">
+ API
+ </span>
+ <span
+ v-if="pipeline.flags.latest"
+ class="js-pipeline-url-lastest label label-success"
+ title="Latest pipeline for this branch"
+ ref="tooltip">
+ latest
+ </span>
+ <span
+ v-if="pipeline.flags.yaml_errors"
+ class="js-pipeline-url-yaml label label-danger"
+ :title="pipeline.yaml_errors"
+ ref="tooltip">
+ yaml invalid
+ </span>
+ <span
+ v-if="pipeline.flags.stuck"
+ class="js-pipeline-url-stuck label label-warning">
+ stuck
+ </span>
+ </td>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.js b/app/assets/javascripts/pipelines/components/pipelines_actions.js
index ffda18d2e0f..b9e066c5db1 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_actions.js
+++ b/app/assets/javascripts/pipelines/components/pipelines_actions.js
@@ -3,6 +3,7 @@
import '~/flash';
import playIconSvg from 'icons/_icon_play.svg';
import eventHub from '../event_hub';
+import loadingIconComponent from '../../vue_shared/components/loading_icon.vue';
export default {
props: {
@@ -17,6 +18,10 @@ export default {
},
},
+ components: {
+ loadingIconComponent,
+ },
+
data() {
return {
playIconSvg,
@@ -65,10 +70,7 @@ export default {
<i
class="fa fa-caret-down"
aria-hidden="true" />
- <i
- v-if="isLoading"
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
+ <loading-icon v-if="isLoading" />
</button>
<ul class="dropdown-menu dropdown-menu-align-right">
diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue
index 310f44b06df..c05c76c9a64 100644
--- a/app/assets/javascripts/pipelines/components/stage.vue
+++ b/app/assets/javascripts/pipelines/components/stage.vue
@@ -15,6 +15,8 @@
/* global Flash */
import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import tooltipMixin from '../../vue_shared/mixins/tooltip';
export default {
props: {
@@ -30,6 +32,10 @@ export default {
},
},
+ mixins: [
+ tooltipMixin,
+ ],
+
data() {
return {
isLoading: false,
@@ -38,6 +44,10 @@ export default {
};
},
+ components: {
+ loadingIcon,
+ },
+
updated() {
if (this.dropdownContent.length > 0) {
this.stopDropdownClickPropagation();
@@ -122,9 +132,10 @@ export default {
<template>
<div class="dropdown">
<button
+ ref="tooltip"
:class="triggerButtonClass"
@click="onClickStage"
- class="mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button"
+ class="mini-pipeline-graph-dropdown-toggle js-builds-dropdown-button"
:title="stage.title"
data-placement="top"
data-toggle="dropdown"
@@ -153,15 +164,7 @@ export default {
: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>
+ <loading-icon v-if="isLoading"/>
<ul
v-else
diff --git a/app/assets/javascripts/pipelines/graph_bundle.js b/app/assets/javascripts/pipelines/graph_bundle.js
deleted file mode 100644
index b7a6b5d8479..00000000000
--- a/app/assets/javascripts/pipelines/graph_bundle.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import Vue from 'vue';
-import pipelineGraph from './components/graph/graph_component.vue';
-
-document.addEventListener('DOMContentLoaded', () => new Vue({
- el: '#js-pipeline-graph-vue',
- components: {
- pipelineGraph,
- },
- render: createElement => createElement('pipeline-graph'),
-}));
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
new file mode 100644
index 00000000000..bfc416da50b
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -0,0 +1,70 @@
+/* global Flash */
+
+import Vue from 'vue';
+import PipelinesMediator from './pipeline_details_mediatior';
+import pipelineGraph from './components/graph/graph_component.vue';
+import pipelineHeader from './components/header_component.vue';
+import eventHub from './event_hub';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const dataset = document.querySelector('.js-pipeline-details-vue').dataset;
+
+ const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
+
+ mediator.fetchPipeline();
+
+ // eslint-disable-next-line
+ new Vue({
+ el: '#js-pipeline-graph-vue',
+ data() {
+ return {
+ mediator,
+ };
+ },
+ components: {
+ pipelineGraph,
+ },
+ render(createElement) {
+ return createElement('pipeline-graph', {
+ props: {
+ isLoading: this.mediator.state.isLoading,
+ pipeline: this.mediator.store.state.pipeline,
+ },
+ });
+ },
+ });
+
+ // eslint-disable-next-line
+ new Vue({
+ el: '#js-pipeline-header-vue',
+ data() {
+ return {
+ mediator,
+ };
+ },
+ components: {
+ pipelineHeader,
+ },
+ created() {
+ eventHub.$on('headerPostAction', this.postAction);
+ },
+ beforeDestroy() {
+ eventHub.$off('headerPostAction', this.postAction);
+ },
+ methods: {
+ postAction(action) {
+ this.mediator.service.postAction(action.path)
+ .then(() => this.mediator.refreshPipeline())
+ .catch(() => new Flash('An error occurred while making the request.'));
+ },
+ },
+ render(createElement) {
+ return createElement('pipeline-header', {
+ props: {
+ isLoading: this.mediator.state.isLoading,
+ pipeline: this.mediator.store.state.pipeline,
+ },
+ });
+ },
+ });
+});
diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediatior.js b/app/assets/javascripts/pipelines/pipeline_details_mediatior.js
new file mode 100644
index 00000000000..82537ea06f5
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_mediatior.js
@@ -0,0 +1,59 @@
+/* global Flash */
+
+import Visibility from 'visibilityjs';
+import Poll from '../lib/utils/poll';
+import PipelineStore from './stores/pipeline_store';
+import PipelineService from './services/pipeline_service';
+
+export default class pipelinesMediator {
+ constructor(options = {}) {
+ this.options = options;
+ this.store = new PipelineStore();
+ this.service = new PipelineService(options.endpoint);
+
+ this.state = {};
+ this.state.isLoading = false;
+ }
+
+ fetchPipeline() {
+ this.poll = new Poll({
+ resource: this.service,
+ method: 'getPipeline',
+ successCallback: this.successCallback.bind(this),
+ errorCallback: this.errorCallback.bind(this),
+ });
+
+ if (!Visibility.hidden()) {
+ this.state.isLoading = true;
+ this.poll.makeRequest();
+ } else {
+ this.refreshPipeline();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
+ }
+
+ successCallback(response) {
+ const data = response.json();
+
+ this.state.isLoading = false;
+ this.store.storePipeline(data);
+ }
+
+ errorCallback() {
+ this.state.isLoading = false;
+ return new Flash('An error occurred while fetching the pipeline.');
+ }
+
+ refreshPipeline() {
+ this.service.getPipeline()
+ .then(response => this.successCallback(response))
+ .catch(() => this.errorCallback());
+ }
+}
diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js
index 934bd7deb31..9f247af1dec 100644
--- a/app/assets/javascripts/pipelines/pipelines.js
+++ b/app/assets/javascripts/pipelines/pipelines.js
@@ -1,12 +1,13 @@
import Visibility from 'visibilityjs';
import PipelinesService from './services/pipelines_service';
import eventHub from './event_hub';
-import PipelinesTableComponent from '../vue_shared/components/pipelines_table';
-import TablePaginationComponent from '../vue_shared/components/table_pagination';
-import EmptyState from './components/empty_state.vue';
-import ErrorState from './components/error_state.vue';
-import NavigationTabs from './components/navigation_tabs';
-import NavigationControls from './components/nav_controls';
+import pipelinesTableComponent from '../vue_shared/components/pipelines_table';
+import tablePagination from '../vue_shared/components/table_pagination.vue';
+import emptyState from './components/empty_state.vue';
+import errorState from './components/error_state.vue';
+import navigationTabs from './components/navigation_tabs';
+import navigationControls from './components/nav_controls';
+import loadingIcon from '../vue_shared/components/loading_icon.vue';
import Poll from '../lib/utils/poll';
export default {
@@ -18,12 +19,13 @@ export default {
},
components: {
- 'gl-pagination': TablePaginationComponent,
- 'pipelines-table-component': PipelinesTableComponent,
- 'empty-state': EmptyState,
- 'error-state': ErrorState,
- 'navigation-tabs': NavigationTabs,
- 'navigation-controls': NavigationControls,
+ tablePagination,
+ pipelinesTableComponent,
+ emptyState,
+ errorState,
+ navigationTabs,
+ navigationControls,
+ loadingIcon,
},
data() {
@@ -50,6 +52,7 @@ export default {
hasError: false,
isMakingRequest: false,
updateGraphDropdown: false,
+ hasMadeRequest: false,
};
},
@@ -76,6 +79,7 @@ export default {
shouldRenderEmptyState() {
return !this.isLoading &&
!this.hasError &&
+ this.hasMadeRequest &&
!this.state.pipelines.length &&
(this.scope === 'all' || this.scope === null);
},
@@ -148,6 +152,10 @@ export default {
if (!Visibility.hidden()) {
this.isLoading = true;
poll.makeRequest();
+ } else {
+ // If tab is not visible we need to make the first request so we don't show the empty
+ // state without knowing if there are any pipelines
+ this.fetchPipelines();
}
Visibility.change(() => {
@@ -161,7 +169,7 @@ export default {
eventHub.$on('refreshPipelines', this.fetchPipelines);
},
- beforeDestroyed() {
+ beforeDestroy() {
eventHub.$off('refreshPipelines');
},
@@ -200,6 +208,7 @@ export default {
this.isLoading = false;
this.updateGraphDropdown = true;
+ this.hasMadeRequest = true;
},
errorCallback() {
@@ -244,13 +253,11 @@ export default {
<div class="content-list pipelines">
- <div
- class="realtime-loading"
- v-if="isLoading">
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- </div>
+ <loading-icon
+ label="Loading Pipelines"
+ size="3"
+ v-if="isLoading"
+ />
<empty-state
v-if="shouldRenderEmptyState"
@@ -275,12 +282,13 @@ export default {
/>
</div>
- <gl-pagination
+ <table-pagination
v-if="shouldRenderPagination"
:pagenum="pagenum"
:change="change"
:count="state.count.all"
- :pageInfo="state.pageInfo"/>
+ :pageInfo="state.pageInfo"
+ />
</div>
</div>
`,
diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js
index f1cc60c1ee0..3e0c52c7726 100644
--- a/app/assets/javascripts/pipelines/services/pipeline_service.js
+++ b/app/assets/javascripts/pipelines/services/pipeline_service.js
@@ -11,4 +11,9 @@ export default class PipelineService {
getPipeline() {
return this.pipeline.get();
}
+
+ // eslint-disable-next-line
+ postAction(endpoint) {
+ return Vue.http.post(`${endpoint}.json`);
+ }
}
diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js
index b21f84b4545..e2285494e62 100644
--- a/app/assets/javascripts/pipelines/services/pipelines_service.js
+++ b/app/assets/javascripts/pipelines/services/pipelines_service.js
@@ -33,8 +33,6 @@ export default class PipelinesService {
/**
* Post request for all pipelines actions.
- * Endpoint content type needs to be:
- * `Content-Type:application/x-www-form-urlencoded`
*
* @param {String} endpoint
* @return {Promise}
diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js
index 86ab50d8f1e..052e34a8aef 100644
--- a/app/assets/javascripts/pipelines/stores/pipeline_store.js
+++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js
@@ -2,10 +2,10 @@ export default class PipelineStore {
constructor() {
this.state = {};
- this.state.graph = [];
+ this.state.pipeline = {};
}
- storeGraph(graph = []) {
- this.state.graph = graph;
+ storePipeline(pipeline = {}) {
+ this.state.pipeline = pipeline;
}
}
diff --git a/app/assets/javascripts/profile/profile_bundle.js b/app/assets/javascripts/profile/profile_bundle.js
index 15d32825583..ff35a9bcb83 100644
--- a/app/assets/javascripts/profile/profile_bundle.js
+++ b/app/assets/javascripts/profile/profile_bundle.js
@@ -1,2 +1,2 @@
-require('./gl_crop');
-require('./profile');
+import './gl_crop';
+import './profile';
diff --git a/app/assets/javascripts/project_edit.js b/app/assets/javascripts/project_edit.js
new file mode 100644
index 00000000000..d7d284b6c86
--- /dev/null
+++ b/app/assets/javascripts/project_edit.js
@@ -0,0 +1,9 @@
+export default function setupProjectEdit() {
+ const $transferForm = $('.js-project-transfer-form');
+ const $selectNamespace = $transferForm.find('.select2');
+
+ $selectNamespace.on('change', () => {
+ $transferForm.find(':submit').prop('disabled', !$selectNamespace.val());
+ });
+ $selectNamespace.trigger('change');
+}
diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js
index e01668eabef..11f9754780d 100644
--- a/app/assets/javascripts/project_find_file.js
+++ b/app/assets/javascripts/project_find_file.js
@@ -2,18 +2,16 @@
/* global fuzzaldrinPlus */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.ProjectFindFile = (function() {
var highlighter;
function ProjectFindFile(element1, options) {
this.element = element1;
this.options = options;
- this.goToBlob = bind(this.goToBlob, this);
- this.goToTree = bind(this.goToTree, this);
- this.selectRowDown = bind(this.selectRowDown, this);
- this.selectRowUp = bind(this.selectRowUp, this);
+ this.goToBlob = this.goToBlob.bind(this);
+ this.goToTree = this.goToTree.bind(this);
+ this.selectRowDown = this.selectRowDown.bind(this);
+ this.selectRowUp = this.selectRowUp.bind(this);
this.filePaths = {};
this.inputElement = this.element.find(".file-finder-input");
// init event
diff --git a/app/assets/javascripts/project_new.js b/app/assets/javascripts/project_new.js
index e9927c1bf51..c0f757269cb 100644
--- a/app/assets/javascripts/project_new.js
+++ b/app/assets/javascripts/project_new.js
@@ -1,13 +1,17 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-vars, one-var, no-underscore-dangle, prefer-template, no-else-return, prefer-arrow-callback, max-len */
-(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
+function highlightChanges($elm) {
+ $elm.addClass('highlight-changes');
+ setTimeout(() => $elm.removeClass('highlight-changes'), 10);
+}
+(function() {
this.ProjectNew = (function() {
function ProjectNew() {
- this.toggleSettings = bind(this.toggleSettings, this);
+ this.toggleSettings = this.toggleSettings.bind(this);
this.$selects = $('.features select');
this.$repoSelects = this.$selects.filter('.js-repo-select');
+ this.$projectSelects = this.$selects.not('.js-repo-select');
$('.project-edit-container').on('ajax:before', (function(_this) {
return function() {
@@ -28,6 +32,42 @@
if (!visibilityContainer) return;
const visibilitySelect = new gl.VisibilitySelect(visibilityContainer);
visibilitySelect.init();
+
+ const $visibilitySelect = $(visibilityContainer).find('select');
+ let projectVisibility = $visibilitySelect.val();
+ const PROJECT_VISIBILITY_PRIVATE = '0';
+
+ $visibilitySelect.on('change', () => {
+ const newProjectVisibility = $visibilitySelect.val();
+
+ if (projectVisibility !== newProjectVisibility) {
+ this.$projectSelects.each((idx, select) => {
+ const $select = $(select);
+ const $options = $select.find('option');
+ const values = $.map($options, e => e.value);
+
+ // if switched to "private", limit visibility options
+ if (newProjectVisibility === PROJECT_VISIBILITY_PRIVATE) {
+ if ($select.val() !== values[0] && $select.val() !== values[1]) {
+ $select.val(values[1]).trigger('change');
+ highlightChanges($select);
+ }
+ $options.slice(2).disable();
+ }
+
+ // if switched from "private", increase visibility for non-disabled options
+ if (projectVisibility === PROJECT_VISIBILITY_PRIVATE) {
+ $options.enable();
+ if ($select.val() !== values[0] && $select.val() !== values[values.length - 1]) {
+ $select.val(values[values.length - 1]).trigger('change');
+ highlightChanges($select);
+ }
+ }
+ });
+
+ projectVisibility = newProjectVisibility;
+ }
+ });
};
ProjectNew.prototype.toggleSettings = function() {
@@ -58,8 +98,10 @@
ProjectNew.prototype.toggleRepoVisibility = function () {
var $repoAccessLevel = $('.js-repo-access-level select');
+ var $lfsEnabledOption = $('.js-lfs-enabled select');
var containerRegistry = document.querySelectorAll('.js-container-registry')[0];
var containerRegistryCheckbox = document.getElementById('project_container_registry_enabled');
+ var prevSelectedVal = parseInt($repoAccessLevel.val(), 10);
this.$repoSelects.find("option[value='" + $repoAccessLevel.val() + "']")
.nextAll()
@@ -73,29 +115,40 @@
var $this = $(this);
var repoSelectVal = parseInt($this.val(), 10);
- $this.find('option').show();
+ $this.find('option').enable();
- if (selectedVal < repoSelectVal) {
- $this.val(selectedVal);
+ if (selectedVal < repoSelectVal || repoSelectVal === prevSelectedVal) {
+ $this.val(selectedVal).trigger('change');
+ highlightChanges($this);
}
- $this.find("option[value='" + selectedVal + "']").nextAll().hide();
+ $this.find("option[value='" + selectedVal + "']").nextAll().disable();
});
if (selectedVal) {
this.$repoSelects.removeClass('disabled');
+ if ($lfsEnabledOption.length) {
+ $lfsEnabledOption.removeClass('disabled');
+ highlightChanges($lfsEnabledOption);
+ }
if (containerRegistry) {
containerRegistry.style.display = '';
}
} else {
this.$repoSelects.addClass('disabled');
+ if ($lfsEnabledOption.length) {
+ $lfsEnabledOption.val('false').addClass('disabled');
+ highlightChanges($lfsEnabledOption);
+ }
if (containerRegistry) {
containerRegistry.style.display = 'none';
containerRegistryCheckbox.checked = false;
}
}
+
+ prevSelectedVal = selectedVal;
}.bind(this));
};
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
index 3c1c1e7dceb..9896b88d487 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -1,5 +1,5 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-var, comma-dangle, object-shorthand, one-var, one-var-declaration-per-line, no-else-return, quotes, max-len */
-/* global Api */
+import Api from './api';
(function() {
this.ProjectSelect = (function() {
@@ -51,6 +51,9 @@
this.groupId = $(select).data('group-id');
this.includeGroups = $(select).data('include-groups');
this.orderBy = $(select).data('order-by') || 'id';
+ this.withIssuesEnabled = $(select).data('with-issues-enabled');
+ this.withMergeRequestsEnabled = $(select).data('with-merge-requests-enabled');
+
placeholder = "Search for project";
if (this.includeGroups) {
placeholder += " or group";
@@ -84,7 +87,11 @@
if (_this.groupId) {
return Api.groupProjects(_this.groupId, query.term, projectsCallback);
} else {
- return Api.projects(query.term, { order_by: _this.orderBy }, projectsCallback);
+ return Api.projects(query.term, {
+ order_by: _this.orderBy,
+ with_issues_enabled: _this.withIssuesEnabled,
+ with_merge_requests_enabled: _this.withMergeRequestsEnabled
+ }, projectsCallback);
}
};
})(this),
diff --git a/app/assets/javascripts/protected_branches/protected_branches_bundle.js b/app/assets/javascripts/protected_branches/protected_branches_bundle.js
index 849c1e31623..874d70a1431 100644
--- a/app/assets/javascripts/protected_branches/protected_branches_bundle.js
+++ b/app/assets/javascripts/protected_branches/protected_branches_bundle.js
@@ -1,5 +1,5 @@
-require('./protected_branch_access_dropdown');
-require('./protected_branch_create');
-require('./protected_branch_dropdown');
-require('./protected_branch_edit');
-require('./protected_branch_edit_list');
+import './protected_branch_access_dropdown';
+import './protected_branch_create';
+import './protected_branch_dropdown';
+import './protected_branch_edit';
+import './protected_branch_edit_list';
diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
index 068e9698e1d..9d045886262 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
@@ -10,7 +10,7 @@ export default class ProtectedTagDropdown {
this.$dropdown = options.$dropdown;
this.$dropdownContainer = this.$dropdown.parent();
this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
- this.$protectedTag = this.$dropdownContainer.find('.create-new-protected-tag');
+ this.$protectedTag = this.$dropdownContainer.find('.js-create-new-protected-tag');
this.buildDropdown();
this.bindEvents();
@@ -73,7 +73,7 @@ export default class ProtectedTagDropdown {
};
this.$dropdownContainer
- .find('.create-new-protected-tag code')
+ .find('.js-create-new-protected-tag code')
.text(tagName);
}
diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js
index da3fb7a6744..ae54fa5f1a9 100644
--- a/app/assets/javascripts/raven/raven_config.js
+++ b/app/assets/javascripts/raven/raven_config.js
@@ -1,4 +1,5 @@
import Raven from 'raven-js';
+import $ from 'jquery';
const IGNORE_ERRORS = [
// Random plugins/extensions
@@ -74,7 +75,7 @@ const RavenConfig = {
},
bindRavenErrors() {
- window.$(document).on('ajaxError.raven', this.handleRavenErrors);
+ $(document).on('ajaxError.raven', this.handleRavenErrors);
},
handleRavenErrors(event, req, config, err) {
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index a9b3de281e1..b71c3097706 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -3,11 +3,9 @@
import Cookies from 'js-cookie';
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.Sidebar = (function() {
function Sidebar(currentUser) {
- this.toggleTodo = bind(this.toggleTodo, this);
+ this.toggleTodo = this.toggleTodo.bind(this);
this.sidebar = $('aside');
this.removeListeners();
this.addEventListeners();
diff --git a/app/assets/javascripts/search.js b/app/assets/javascripts/search.js
index 15f5963353a..05caf177aec 100644
--- a/app/assets/javascripts/search.js
+++ b/app/assets/javascripts/search.js
@@ -1,5 +1,6 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, object-shorthand, prefer-arrow-callback, comma-dangle, prefer-template, quotes, no-else-return, max-len */
-/* global Api */
+/* global Flash */
+import Api from './api';
(function() {
this.Search = (function() {
@@ -7,6 +8,7 @@
var $groupDropdown, $projectDropdown;
$groupDropdown = $('.js-search-group-dropdown');
$projectDropdown = $('.js-search-project-dropdown');
+ this.groupId = $groupDropdown.data('group-id');
this.eventListeners();
$groupDropdown.glDropdown({
selectable: true,
@@ -46,14 +48,18 @@
search: {
fields: ['name']
},
- data: function(term, callback) {
- return Api.projects(term, { order_by: 'id' }, function(data) {
- data.unshift({
- name_with_namespace: 'Any'
- });
- data.splice(1, 0, 'divider');
- return callback(data);
- });
+ data: (term, callback) => {
+ this.getProjectsData(term)
+ .then((data) => {
+ data.unshift({
+ name_with_namespace: 'Any'
+ });
+ data.splice(1, 0, 'divider');
+
+ return data;
+ })
+ .then(data => callback(data))
+ .catch(() => new Flash('Error fetching projects'));
},
id: function(obj) {
return obj.id;
@@ -95,6 +101,18 @@
return $('.js-search-input').val('').trigger('keyup').focus();
};
+ Search.prototype.getProjectsData = function(term) {
+ return new Promise((resolve) => {
+ if (this.groupId) {
+ Api.groupProjects(this.groupId, term, resolve);
+ } else {
+ Api.projects(term, {
+ order_by: 'id',
+ }, resolve);
+ }
+ });
+ };
+
return Search;
})();
}).call(window);
diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js
new file mode 100644
index 00000000000..e67f449e1a2
--- /dev/null
+++ b/app/assets/javascripts/settings_panels.js
@@ -0,0 +1,27 @@
+function expandSection($section) {
+ $section.find('.js-settings-toggle').text('Close');
+ $section.find('.settings-content').addClass('expanded').off('scroll').scrollTop(0);
+}
+
+function closeSection($section) {
+ $section.find('.js-settings-toggle').text('Expand');
+ $section.find('.settings-content').removeClass('expanded').on('scroll', () => expandSection($section));
+}
+
+function toggleSection($section) {
+ const $content = $section.find('.settings-content');
+ $content.removeClass('no-animate');
+ if ($content.hasClass('expanded')) {
+ closeSection($section);
+ } else {
+ expandSection($section);
+ }
+}
+
+export default function initSettingsPanels() {
+ $('.settings').each((i, elm) => {
+ const $section = $(elm);
+ $section.on('click', '.js-settings-toggle', () => toggleSection($section));
+ $section.find('.settings-content:not(.expanded)').on('scroll', () => expandSection($section));
+ });
+}
diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js
index 85659d7fa39..8ac71797c14 100644
--- a/app/assets/javascripts/shortcuts.js
+++ b/app/assets/javascripts/shortcuts.js
@@ -4,11 +4,9 @@
import findAndFollowLink from './shortcuts_dashboard_navigation';
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.Shortcuts = (function() {
function Shortcuts(skipResetBindings) {
- this.onToggleHelp = bind(this.onToggleHelp, this);
+ this.onToggleHelp = this.onToggleHelp.bind(this);
this.enabledHelp = [];
if (!skipResetBindings) {
Mousetrap.reset();
diff --git a/app/assets/javascripts/shortcuts_blob.js b/app/assets/javascripts/shortcuts_blob.js
index bfe90aef71e..ccbf7c59165 100644
--- a/app/assets/javascripts/shortcuts_blob.js
+++ b/app/assets/javascripts/shortcuts_blob.js
@@ -1,14 +1,14 @@
/* global Mousetrap */
/* global Shortcuts */
-require('./shortcuts');
+import './shortcuts';
const defaults = {
skipResetBindings: false,
fileBlobPermalinkUrl: null,
};
-class ShortcutsBlob extends Shortcuts {
+export default class ShortcutsBlob extends Shortcuts {
constructor(opts) {
const options = Object.assign({}, defaults, opts);
super(options.skipResetBindings);
@@ -25,5 +25,3 @@ class ShortcutsBlob extends Shortcuts {
}
}
}
-
-module.exports = ShortcutsBlob;
diff --git a/app/assets/javascripts/shortcuts_find_file.js b/app/assets/javascripts/shortcuts_find_file.js
index a27ac264a5c..b18b6139b35 100644
--- a/app/assets/javascripts/shortcuts_find_file.js
+++ b/app/assets/javascripts/shortcuts_find_file.js
@@ -2,7 +2,7 @@
/* global Mousetrap */
/* global ShortcutsNavigation */
-require('./shortcuts_navigation');
+import './shortcuts_navigation';
(function() {
var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js
index fe58e98cee5..51448252c0f 100644
--- a/app/assets/javascripts/shortcuts_issuable.js
+++ b/app/assets/javascripts/shortcuts_issuable.js
@@ -3,8 +3,8 @@
/* global ShortcutsNavigation */
/* global sidebar */
-require('mousetrap');
-require('./shortcuts_navigation');
+import 'mousetrap';
+import './shortcuts_navigation';
(function() {
var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
@@ -38,7 +38,7 @@ require('./shortcuts_navigation');
}
ShortcutsIssuable.prototype.replyWithSelectedText = function() {
- var quote, documentFragment, selected, separator;
+ var quote, documentFragment, el, selected, separator;
var replyField = $('.js-main-target-form #note_note');
documentFragment = window.gl.utils.getSelectedFragment();
@@ -47,10 +47,8 @@ require('./shortcuts_navigation');
return;
}
- // If the documentFragment contains more than just Markdown, don't copy as GFM.
- if (documentFragment.querySelector('.md, .wiki')) return;
-
- selected = window.gl.CopyAsGFM.nodeToGFM(documentFragment);
+ el = window.gl.CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true));
+ selected = window.gl.CopyAsGFM.nodeToGFM(el);
if (selected.trim() === "") {
return;
@@ -79,7 +77,9 @@ require('./shortcuts_navigation');
ShortcutsIssuable.prototype.editIssue = function() {
var $editBtn;
$editBtn = $('.issuable-edit');
- return gl.utils.visitUrl($editBtn.attr('href'));
+ // Need to click the element as on issues, editing is inline
+ // on merge request, editing is on a different page
+ $editBtn.get(0).click();
};
ShortcutsIssuable.prototype.openSidebarDropdown = function(name) {
diff --git a/app/assets/javascripts/shortcuts_navigation.js b/app/assets/javascripts/shortcuts_navigation.js
index c74ab0afd0c..55bae0c08a1 100644
--- a/app/assets/javascripts/shortcuts_navigation.js
+++ b/app/assets/javascripts/shortcuts_navigation.js
@@ -1,9 +1,9 @@
/* eslint-disable func-names, space-before-function-paren, max-len, no-var, one-var, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, prefer-arrow-callback, consistent-return, no-return-assign */
/* global Mousetrap */
/* global Shortcuts */
-import findAndFollowLink from './shortcuts_dashboard_navigation';
-require('./shortcuts');
+import findAndFollowLink from './shortcuts_dashboard_navigation';
+import './shortcuts';
(function() {
var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
diff --git a/app/assets/javascripts/shortcuts_network.js b/app/assets/javascripts/shortcuts_network.js
index 4c2bf8bf001..cc44082efa9 100644
--- a/app/assets/javascripts/shortcuts_network.js
+++ b/app/assets/javascripts/shortcuts_network.js
@@ -2,7 +2,7 @@
/* global Mousetrap */
/* global ShortcutsNavigation */
-require('./shortcuts_navigation');
+import './shortcuts_navigation';
(function() {
var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index 294d087554e..c44892dae3d 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -1,12 +1,10 @@
/* eslint-disable func-names, prefer-arrow-callback, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, consistent-return, no-param-reassign, max-len */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
window.SingleFileDiff = (function() {
var COLLAPSED_HTML, ERROR_HTML, LOADING_HTML, WRAPPER;
- WRAPPER = '<div class="diff-content diff-wrap-lines"></div>';
+ WRAPPER = '<div class="diff-content"></div>';
LOADING_HTML = '<i class="fa fa-spinner fa-spin"></i>';
@@ -16,7 +14,7 @@
function SingleFileDiff(file) {
this.file = file;
- this.toggleDiff = bind(this.toggleDiff, this);
+ this.toggleDiff = this.toggleDiff.bind(this);
this.content = $('.diff-content', this.file);
this.$toggleIcon = $('.diff-toggle-caret', this.file);
this.diffForPath = this.content.find('[data-diff-for-path]').data('diff-for-path');
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index b1402c0a880..419c458ff34 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -1,5 +1,6 @@
/* global Flash */
-require('vendor/task_list');
+
+import 'deckar01-task_list';
class TaskList {
constructor(options = {}) {
diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/templates/issuable_template_selector.js
index e62f429f1ae..9dd14488f22 100644
--- a/app/assets/javascripts/templates/issuable_template_selector.js
+++ b/app/assets/javascripts/templates/issuable_template_selector.js
@@ -1,5 +1,5 @@
/* eslint-disable comma-dangle, max-len, no-useless-return, no-param-reassign, max-len */
-/* global Api */
+import Api from '../api';
import TemplateSelector from '../blob/template_selector';
diff --git a/app/assets/javascripts/terminal/terminal_bundle.js b/app/assets/javascripts/terminal/terminal_bundle.js
index 13cf3a10a38..134522ef961 100644
--- a/app/assets/javascripts/terminal/terminal_bundle.js
+++ b/app/assets/javascripts/terminal/terminal_bundle.js
@@ -1,7 +1,9 @@
-require('vendor/xterm/encoding-indexes.js');
-require('vendor/xterm/encoding.js');
-window.Terminal = require('vendor/xterm/xterm.js');
-require('vendor/xterm/fit.js');
-require('./terminal.js');
+import 'vendor/xterm/encoding-indexes';
+import 'vendor/xterm/encoding';
+import Terminal from 'vendor/xterm/xterm';
+import 'vendor/xterm/fit';
+import './terminal';
+
+window.Terminal = Terminal;
$(() => new gl.Terminal({ selector: '#terminal' }));
diff --git a/app/assets/javascripts/todos.js b/app/assets/javascripts/todos.js
index 8be58023c84..7230946b484 100644
--- a/app/assets/javascripts/todos.js
+++ b/app/assets/javascripts/todos.js
@@ -1,5 +1,6 @@
/* eslint-disable class-methods-use-this, no-unneeded-ternary, quote-props */
-/* global UsersSelect */
+
+import UsersSelect from './users_select';
class Todos {
constructor() {
diff --git a/app/assets/javascripts/u2f/authenticate.js b/app/assets/javascripts/u2f/authenticate.js
index 500b78fc5d8..cd5280948fd 100644
--- a/app/assets/javascripts/u2f/authenticate.js
+++ b/app/assets/javascripts/u2f/authenticate.js
@@ -10,18 +10,16 @@
(function() {
const global = window.gl || (window.gl = {});
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
global.U2FAuthenticate = (function() {
function U2FAuthenticate(container, form, u2fParams, fallbackButton, fallbackUI) {
this.container = container;
- this.renderNotSupported = bind(this.renderNotSupported, this);
- this.renderAuthenticated = bind(this.renderAuthenticated, this);
- this.renderError = bind(this.renderError, this);
- this.renderInProgress = bind(this.renderInProgress, this);
- this.renderTemplate = bind(this.renderTemplate, this);
- this.authenticate = bind(this.authenticate, this);
- this.start = bind(this.start, this);
+ this.renderNotSupported = this.renderNotSupported.bind(this);
+ this.renderAuthenticated = this.renderAuthenticated.bind(this);
+ this.renderError = this.renderError.bind(this);
+ this.renderInProgress = this.renderInProgress.bind(this);
+ this.renderTemplate = this.renderTemplate.bind(this);
+ this.authenticate = this.authenticate.bind(this);
+ this.start = this.start.bind(this);
this.appId = u2fParams.app_id;
this.challenge = u2fParams.challenge;
this.form = form;
diff --git a/app/assets/javascripts/u2f/error.js b/app/assets/javascripts/u2f/error.js
index fd1829efe18..3119b3480c3 100644
--- a/app/assets/javascripts/u2f/error.js
+++ b/app/assets/javascripts/u2f/error.js
@@ -2,12 +2,10 @@
/* global u2f */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.U2FError = (function() {
function U2FError(errorCode, u2fFlowType) {
this.errorCode = errorCode;
- this.message = bind(this.message, this);
+ this.message = this.message.bind(this);
this.httpsDisabled = window.location.protocol !== 'https:';
this.u2fFlowType = u2fFlowType;
}
diff --git a/app/assets/javascripts/u2f/register.js b/app/assets/javascripts/u2f/register.js
index 17631f2908d..1234d17b8fd 100644
--- a/app/assets/javascripts/u2f/register.js
+++ b/app/assets/javascripts/u2f/register.js
@@ -8,19 +8,17 @@
// State Flow #1: setup -> in_progress -> registered -> POST to server
// State Flow #2: setup -> in_progress -> error -> setup
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.U2FRegister = (function() {
function U2FRegister(container, u2fParams) {
this.container = container;
- this.renderNotSupported = bind(this.renderNotSupported, this);
- this.renderRegistered = bind(this.renderRegistered, this);
- this.renderError = bind(this.renderError, this);
- this.renderInProgress = bind(this.renderInProgress, this);
- this.renderSetup = bind(this.renderSetup, this);
- this.renderTemplate = bind(this.renderTemplate, this);
- this.register = bind(this.register, this);
- this.start = bind(this.start, this);
+ this.renderNotSupported = this.renderNotSupported.bind(this);
+ this.renderRegistered = this.renderRegistered.bind(this);
+ this.renderError = this.renderError.bind(this);
+ this.renderInProgress = this.renderInProgress.bind(this);
+ this.renderSetup = this.renderSetup.bind(this);
+ this.renderTemplate = this.renderTemplate.bind(this);
+ this.register = this.register.bind(this);
+ this.start = this.start.bind(this);
this.appId = u2fParams.app_id;
this.registerRequests = u2fParams.register_requests;
this.signRequests = u2fParams.sign_requests;
diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js
index b9d57cbcad4..ff2208baeab 100644
--- a/app/assets/javascripts/user_callout.js
+++ b/app/assets/javascripts/user_callout.js
@@ -1,11 +1,10 @@
import Cookies from 'js-cookie';
-const USER_CALLOUT_COOKIE = 'user_callout_dismissed';
-
export default class UserCallout {
- constructor() {
- this.isCalloutDismissed = Cookies.get(USER_CALLOUT_COOKIE);
- this.userCalloutBody = $('.user-callout');
+ constructor(className = 'user-callout') {
+ this.userCalloutBody = $(`.${className}`);
+ this.cookieName = this.userCalloutBody.data('uid');
+ this.isCalloutDismissed = Cookies.get(this.cookieName);
this.init();
}
@@ -18,7 +17,7 @@ export default class UserCallout {
dismissCallout(e) {
const $currentTarget = $(e.currentTarget);
- Cookies.set(USER_CALLOUT_COOKIE, 'true', { expires: 365 });
+ Cookies.set(this.cookieName, 'true', { expires: 365 });
if ($currentTarget.hasClass('close')) {
this.userCalloutBody.remove();
diff --git a/app/assets/javascripts/users/calendar.js b/app/assets/javascripts/users/calendar.js
index 32ffa2f0ac0..b11f691e424 100644
--- a/app/assets/javascripts/users/calendar.js
+++ b/app/assets/javascripts/users/calendar.js
@@ -3,12 +3,10 @@
import d3 from 'd3';
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.Calendar = (function() {
function Calendar(timestamps, calendar_activities_path) {
this.calendar_activities_path = calendar_activities_path;
- this.clickDay = bind(this.clickDay, this);
+ this.clickDay = this.clickDay.bind(this);
this.currentSelectedDate = '';
this.daySpace = 1;
this.daySize = 15;
diff --git a/app/assets/javascripts/users/users_bundle.js b/app/assets/javascripts/users/users_bundle.js
index 580e2d84be5..a38ce4eb25e 100644
--- a/app/assets/javascripts/users/users_bundle.js
+++ b/app/assets/javascripts/users/users_bundle.js
@@ -1 +1 @@
-require('./calendar');
+import './calendar';
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 4b4710fafee..b25e638902c 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -5,676 +5,701 @@
// TODO: remove eventHub hack after code splitting refactor
window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
-(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
- slice = [].slice;
-
- this.UsersSelect = (function() {
- function UsersSelect(currentUser, els) {
- var $els;
- this.users = bind(this.users, this);
- this.user = bind(this.user, this);
- this.usersPath = "/autocomplete/users.json";
- this.userPath = "/autocomplete/users/:id.json";
- if (currentUser != null) {
- if (typeof currentUser === 'object') {
- this.currentUser = currentUser;
- } else {
- this.currentUser = JSON.parse(currentUser);
- }
+function UsersSelect(currentUser, els) {
+ var $els;
+ this.users = this.users.bind(this);
+ this.user = this.user.bind(this);
+ this.usersPath = "/autocomplete/users.json";
+ this.userPath = "/autocomplete/users/:id.json";
+ if (currentUser != null) {
+ if (typeof currentUser === 'object') {
+ this.currentUser = currentUser;
+ } else {
+ this.currentUser = JSON.parse(currentUser);
+ }
+ }
+
+ $els = $(els);
+
+ if (!els) {
+ $els = $('.js-user-search');
+ }
+
+ $els.each((function(_this) {
+ return function(i, dropdown) {
+ var options = {};
+ 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');
+ options.showCurrentUser = $dropdown.data('current-user');
+ options.todoFilter = $dropdown.data('todo-filter');
+ options.todoStateFilter = $dropdown.data('todo-state-filter');
+ options.perPage = $dropdown.data('per-page');
+ 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');
+ defaultLabel = $dropdown.data('default-label');
+ issueURL = $dropdown.data('issueUpdate');
+ $selectbox = $dropdown.closest('.selectbox');
+ $block = $selectbox.closest('.block');
+ abilityName = $dropdown.data('ability-name');
+ $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');
+
+ if (selectedId === undefined) {
+ selectedId = selectedIdDefault;
}
- $els = $(els);
+ 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');
- if (!els) {
- $els = $('.js-user-search');
+ 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);
}
- $els.each((function(_this) {
- return function(i, dropdown) {
- var options = {};
- 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');
- options.showCurrentUser = $dropdown.data('current-user');
- 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');
- defaultLabel = $dropdown.data('default-label');
- issueURL = $dropdown.data('issueUpdate');
- $selectbox = $dropdown.closest('.selectbox');
- $block = $selectbox.closest('.block');
- abilityName = $dropdown.data('ability-name');
- $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');
-
- if (selectedId === undefined) {
- selectedId = selectedIdDefault;
+ 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();
+ emitSidebarEvent('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`;
+ }
+ };
- const assignYourself = function () {
- const unassignedSelected = $dropdown.closest('.selectbox')
- .find(`input[name='${$dropdown.data('field-name')}'][value=0]`);
-
- if (unassignedSelected) {
- unassignedSelected.remove();
- }
+ $('.assign-to-me-link').on('click', (e) => {
+ e.preventDefault();
+ $(e.currentTarget).hide();
- // Save current selected user to the DOM
- const input = document.createElement('input');
- input.type = 'hidden';
- input.name = $dropdown.data('field-name');
+ if ($dropdown.data('multiSelect')) {
+ assignYourself();
+ checkMaxSelect();
- const currentUserInfo = $dropdown.data('currentUserInfo');
+ const currentUserInfo = $dropdown.data('currentUserInfo');
+ $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default');
+ } else {
+ 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');
+ }
+ });
- if (currentUserInfo) {
- input.value = currentUserInfo.id;
- input.dataset.meta = currentUserInfo.name;
- } else if (_this.currentUser) {
- input.value = _this.currentUser.id;
- }
+ $block.on('click', '.js-assign-yourself', (e) => {
+ e.preventDefault();
+ return assignTo(_this.currentUser.id);
+ });
- if ($selectbox) {
- $dropdown.parent().before(input);
- } else {
- $dropdown.after(input);
- }
- };
+ assignTo = function(selected) {
+ var data;
+ data = {};
+ data[abilityName] = {};
+ data[abilityName].assignee_id = selected != null ? selected : null;
+ $loading.removeClass('hidden').fadeIn();
+ $dropdown.trigger('loading.gl.dropdown');
+
+ return $.ajax({
+ type: 'PUT',
+ dataType: 'json',
+ url: issueURL,
+ data: data
+ }).done(function(data) {
+ var user;
+ $dropdown.trigger('loaded.gl.dropdown');
+ $loading.fadeOut();
+ if (data.assignee) {
+ user = {
+ name: data.assignee.name,
+ username: data.assignee.username,
+ avatar: data.assignee.avatar_url
+ };
+ } else {
+ user = {
+ name: 'Unassigned',
+ username: '',
+ avatar: ''
+ };
+ }
+ $value.html(assigneeTemplate(user));
+ $collapsedSidebar.attr('title', user.name).tooltip('fixTitle');
+ return $collapsedSidebar.html(collapsedAssigneeTemplate(user));
+ });
+ };
+ collapsedAssigneeTemplate = _.template('<% if( avatar ) { %> <a class="author_link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>');
+ assigneeTemplate = _.template('<% if (username) { %> <a class="author_link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>');
+ return $dropdown.glDropdown({
+ showMenuAbove: showMenuAbove,
+ data: function(term, callback) {
+ var isAuthorFilter;
+ isAuthorFilter = $('.js-author-search');
+ return _this.users(term, options, function(users) {
+ // 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, data, callback) {
+ let users = data;
+
+ // Only show assigned user list when there is no search term
+ if ($dropdown.hasClass('js-multiselect') && term.length === 0) {
+ const selectedInputs = getSelectedUserInputs();
+
+ // Potential duplicate entries when dealing with issue board
+ // because issue board is also managed by vue
+ const selectedUsers = _.uniq(selectedInputs, false, a => a.value)
+ .filter((input) => {
+ const userId = parseInt(input.value, 10);
+ const inUsersArray = users.find(u => u.id === userId);
+
+ return !inUsersArray && userId !== 0;
+ })
+ .map((input) => {
+ const userId = parseInt(input.value, 10);
+ const { avatarUrl, avatar_url, name, username } = input.dataset;
+ return {
+ avatar_url: avatarUrl || avatar_url,
+ id: userId,
+ name,
+ username,
+ };
+ });
- if ($block[0]) {
- $block[0].addEventListener('assignYourself', assignYourself);
+ users = data.concat(selectedUsers);
}
- 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();
- emitSidebarEvent('sidebar.removeAssignee', {
- id: firstSelectedId,
- });
+ 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;
+ }
}
}
- };
-
- 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`;
+ 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);
}
- };
-
- $('.assign-to-me-link').on('click', (e) => {
- e.preventDefault();
- $(e.currentTarget).hide();
-
- if ($dropdown.data('multiSelect')) {
- assignYourself();
- checkMaxSelect();
- const currentUserInfo = $dropdown.data('currentUserInfo');
- $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default');
- } else {
- 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');
+ if (showDivider) {
+ users.splice(showDivider, 0, 'divider');
}
- });
- $block.on('click', '.js-assign-yourself', (e) => {
- e.preventDefault();
- return assignTo(_this.currentUser.id);
- });
+ if ($dropdown.hasClass('js-multiselect')) {
+ const selected = getSelected().filter(i => i !== 0);
- assignTo = function(selected) {
- var data;
- data = {};
- data[abilityName] = {};
- data[abilityName].assignee_id = selected != null ? selected : null;
- $loading.removeClass('hidden').fadeIn();
- $dropdown.trigger('loading.gl.dropdown');
-
- return $.ajax({
- type: 'PUT',
- dataType: 'json',
- url: issueURL,
- data: data
- }).done(function(data) {
- var user;
- $dropdown.trigger('loaded.gl.dropdown');
- $loading.fadeOut();
- if (data.assignee) {
- user = {
- name: data.assignee.name,
- username: data.assignee.username,
- avatar: data.assignee.avatar_url
- };
- } else {
- user = {
- name: 'Unassigned',
- username: '',
- avatar: ''
- };
- }
- $value.html(assigneeTemplate(user));
- $collapsedSidebar.attr('title', user.name).tooltip('fixTitle');
- return $collapsedSidebar.html(collapsedAssigneeTemplate(user));
- });
- };
- collapsedAssigneeTemplate = _.template('<% if( avatar ) { %> <a class="author_link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>');
- assigneeTemplate = _.template('<% if (username) { %> <a class="author_link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>');
- return $dropdown.glDropdown({
- showMenuAbove: showMenuAbove,
- data: function(term, callback) {
- var isAuthorFilter;
- isAuthorFilter = $('.js-author-search');
- return _this.users(term, options, function(users) {
- // 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) {
+ if (selected.length > 0) {
+ if ($dropdown.data('dropdown-header')) {
showDivider += 1;
- users.unshift({
- beforeDivider: true,
- name: 'Unassigned',
- id: 0
+ users.splice(showDivider, 0, {
+ header: $dropdown.data('dropdown-header'),
});
}
- 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');
- }
+ const selectedUsers = users
+ .filter(u => selected.indexOf(u.id) !== -1)
+ .sort((a, b) => a.name > b.name);
- if ($dropdown.hasClass('js-multiselect')) {
- const selected = getSelected().filter(i => i !== 0);
+ users = users.filter(u => selected.indexOf(u.id) === -1);
- 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);
+ selectedUsers.forEach((selectedUser) => {
+ showDivider += 1;
+ users.splice(showDivider, 0, selectedUser);
+ });
- users = users.filter(u => selected.indexOf(u.id) === -1);
+ users.splice(showDivider + 1, 0, 'divider');
+ }
+ }
+ }
- selectedUsers.forEach((selectedUser) => {
- showDivider += 1;
- users.splice(showDivider, 0, selectedUser);
- });
+ callback(users);
+ if (showMenuAbove) {
+ $dropdown.data('glDropdown').positionMenuAbove();
+ }
+ },
+ filterable: true,
+ filterRemote: true,
+ search: {
+ fields: ['name', 'username']
+ },
+ selectable: true,
+ fieldName: $dropdown.data('field-name'),
+ 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);
+ }
- users.splice(showDivider + 1, 0, 'divider');
- }
- }
- }
+ if (this.multiSelect) {
+ return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active'));
+ }
- callback(users);
- if (showMenuAbove) {
- $dropdown.data('glDropdown').positionMenuAbove();
- }
- },
- filterable: true,
- filterRemote: true,
- search: {
- fields: ['name', 'username']
- },
- selectable: true,
- fieldName: $dropdown.data('field-name'),
- 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 (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,
+ hidden: function(e) {
+ if ($dropdown.hasClass('js-multiselect')) {
+ emitSidebarEvent('sidebar.saveAssignees');
+ }
- if (this.multiSelect) {
- return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active'));
- }
+ if (!$dropdown.data('always-show-selectbox')) {
+ $selectbox.hide();
- 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,
- hidden: function(e) {
- if ($dropdown.hasClass('js-multiselect')) {
- emitSidebarEvent('sidebar.saveAssignees');
- }
+ // 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', '');
+ }
+ },
+ 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();
+ });
+ emitSidebarEvent('sidebar.removeAllAssignees');
+ } else if (isActive) {
+ // user selected
+ emitSidebarEvent('sidebar.addAssignee', user);
- if (!$dropdown.data('always-show-selectbox')) {
- $selectbox.hide();
+ // Remove unassigned selection (if it was previously selected)
+ const unassignedSelected = $dropdown.closest('.selectbox')
+ .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]");
- // 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', '');
+ 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, {});
}
- },
- 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();
- });
- emitSidebarEvent('sidebar.removeAllAssignees');
- } else if (isActive) {
- // user selected
- emitSidebarEvent('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]");
+ // User unselected
+ emitSidebarEvent('sidebar.removeAssignee', user);
+ }
- 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, {});
- }
+ if (getSelected().find(u => u === gon.current_user_id)) {
+ $('.assign-to-me-link').hide();
+ } else {
+ $('.assign-to-me-link').show();
+ }
+ }
- // User unselected
- emitSidebarEvent('sidebar.removeAssignee', user);
- }
+ 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();
- if (getSelected().find(u => u === gon.current_user_id)) {
- $('.assign-to-me-link').hide();
- } else {
- $('.assign-to-me-link').show();
- }
- }
+ const isSelecting = (user.id !== selectedId);
+ selectedId = isSelecting ? user.id : selectedIdDefault;
- 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();
+ if (selectedId === gon.current_user_id) {
+ $('.assign-to-me-link').hide();
+ } else {
+ $('.assign-to-me-link').show();
+ }
+ return;
+ }
+ 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)) {
+ return Issuable.filterResults($dropdown.closest('form'));
+ } else if ($dropdown.hasClass('js-filter-submit')) {
+ return $dropdown.closest('form').submit();
+ } else if (!$dropdown.hasClass('js-multiselect')) {
+ selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val();
+ return assignTo(selected);
+ }
- const isSelecting = (user.id !== selectedId);
- selectedId = isSelecting ? user.id : selectedIdDefault;
+ // Automatically close dropdown after assignee is selected
+ // since CE has no multiple assignees
+ // EE does not have a max-select
+ if ($dropdown.data('max-select') &&
+ getSelected().length === $dropdown.data('max-select')) {
+ // Close the dropdown
+ $dropdown.dropdown('toggle');
+ }
+ },
+ id: function (user) {
+ return user.id;
+ },
+ opened: function(e) {
+ const $el = $(e.currentTarget);
+ const selected = getSelected();
+ if ($dropdown.hasClass('js-issue-board-sidebar') && selected.length === 0) {
+ this.addInput($dropdown.data('field-name'), 0, {});
+ }
+ $el.find('.is-active').removeClass('is-active');
- if (selectedId === gon.current_user_id) {
- $('.assign-to-me-link').hide();
- } else {
- $('.assign-to-me-link').show();
- }
- return;
- }
- 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)) {
- return Issuable.filterResults($dropdown.closest('form'));
- } else if ($dropdown.hasClass('js-filter-submit')) {
- return $dropdown.closest('form').submit();
- } else if (!$dropdown.hasClass('js-multiselect')) {
- selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val();
- return assignTo(selected);
- }
+ function highlightSelected(id) {
+ $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active');
+ }
- // Automatically close dropdown after assignee is selected
- // since CE has no multiple assignees
- // EE does not have a max-select
- if ($dropdown.data('max-select') &&
- getSelected().length === $dropdown.data('max-select')) {
- // Close the dropdown
- $dropdown.dropdown('toggle');
- }
- },
- id: function (user) {
- return user.id;
- },
- opened: function(e) {
- const $el = $(e.currentTarget);
- const selected = getSelected();
- if ($dropdown.hasClass('js-issue-board-sidebar') && selected.length === 0) {
- this.addInput($dropdown.data('field-name'), 0, {});
- }
- $el.find('.is-active').removeClass('is-active');
+ if (selected.length > 0) {
+ getSelected().forEach(selectedId => highlightSelected(selectedId));
+ } else if ($dropdown.hasClass('js-issue-board-sidebar')) {
+ highlightSelected(0);
+ } else {
+ highlightSelected(selectedId);
+ }
+ },
+ updateLabel: $dropdown.data('dropdown-title'),
+ renderRow: function(user) {
+ var avatar, img, listClosingTags, listWithName, listWithUserName, username;
+ username = user.username ? "@" + user.username : "";
+ avatar = user.avatar_url ? user.avatar_url : false;
- function highlightSelected(id) {
- $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active');
- }
+ let selected = false;
- if (selected.length > 0) {
- getSelected().forEach(selectedId => highlightSelected(selectedId));
- } else if ($dropdown.hasClass('js-issue-board-sidebar')) {
- highlightSelected(0);
- } else {
- highlightSelected(selectedId);
- }
- },
- updateLabel: $dropdown.data('dropdown-title'),
- renderRow: function(user) {
- var avatar, img, listClosingTags, listWithName, listWithUserName, username;
- username = user.username ? "@" + user.username : "";
- avatar = user.avatar_url ? user.avatar_url : false;
+ if (this.multiSelect) {
+ selected = getSelected().find(u => user.id === u);
- let selected = false;
+ const fieldName = this.fieldName;
+ const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']");
- if (this.multiSelect) {
- selected = getSelected().find(u => user.id === u);
+ if (field.length) {
+ selected = true;
+ }
+ } else {
+ selected = user.id === selectedId;
+ }
- const fieldName = this.fieldName;
- const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']");
+ img = "";
+ if (user.beforeDivider != null) {
+ `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`;
+ } else {
+ if (avatar) {
+ img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />";
+ }
+ }
- if (field.length) {
- selected = true;
+ 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>
+ `;
+ }
+ });
+ };
+ })(this));
+ $('.ajax-users-select').each((function(_this) {
+ return function(i, select) {
+ var firstUser, showAnyUser, showEmailUser, showNullUser;
+ var options = {};
+ options.skipLdap = $(select).hasClass('skip_ldap');
+ options.projectId = $(select).data('project-id');
+ options.groupId = $(select).data('group-id');
+ options.showCurrentUser = $(select).data('current-user');
+ options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches');
+ options.authorId = $(select).data('author-id');
+ options.skipUsers = $(select).data('skip-users');
+ showNullUser = $(select).data('null-user');
+ showAnyUser = $(select).data('any-user');
+ showEmailUser = $(select).data('email-user');
+ firstUser = $(select).data('first-user');
+ return $(select).select2({
+ placeholder: "Search for a user",
+ multiple: $(select).hasClass('multiselect'),
+ minimumInputLength: 0,
+ query: function(query) {
+ return _this.users(query.term, options, function(users) {
+ var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref;
+ data = {
+ results: users
+ };
+ if (query.term.length === 0) {
+ if (firstUser) {
+ // Move current user to the front of the list
+ ref = data.results;
+ for (index = j = 0, len = ref.length; j < len; index = (j += 1)) {
+ obj = ref[index];
+ if (obj.username === firstUser) {
+ data.results.splice(index, 1);
+ data.results.unshift(obj);
+ break;
+ }
}
} else {
selected = user.id === selectedId;
}
-
- img = "";
- if (user.beforeDivider != null) {
- `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`;
- } else {
- if (avatar) {
- img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />";
+ if (showNullUser) {
+ nullUser = {
+ name: 'Unassigned',
+ id: 0
+ };
+ data.results.unshift(nullUser);
+ }
+ if (showAnyUser) {
+ name = showAnyUser;
+ if (name === true) {
+ name = 'Any User';
}
+ anyUser = {
+ name: name,
+ id: null
+ };
+ data.results.unshift(anyUser);
}
-
- 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>
- `;
}
- });
- };
- })(this));
- $('.ajax-users-select').each((function(_this) {
- return function(i, select) {
- var firstUser, showAnyUser, showEmailUser, showNullUser;
- var options = {};
- options.skipLdap = $(select).hasClass('skip_ldap');
- options.projectId = $(select).data('project-id');
- options.groupId = $(select).data('group-id');
- options.showCurrentUser = $(select).data('current-user');
- options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches');
- options.authorId = $(select).data('author-id');
- options.skipUsers = $(select).data('skip-users');
- showNullUser = $(select).data('null-user');
- showAnyUser = $(select).data('any-user');
- showEmailUser = $(select).data('email-user');
- firstUser = $(select).data('first-user');
- return $(select).select2({
- placeholder: "Search for a user",
- multiple: $(select).hasClass('multiselect'),
- minimumInputLength: 0,
- query: function(query) {
- return _this.users(query.term, options, function(users) {
- var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref;
- data = {
- results: users
- };
- if (query.term.length === 0) {
- if (firstUser) {
- // Move current user to the front of the list
- ref = data.results;
- for (index = j = 0, len = ref.length; j < len; index = (j += 1)) {
- obj = ref[index];
- if (obj.username === firstUser) {
- data.results.splice(index, 1);
- data.results.unshift(obj);
- break;
- }
- }
- }
- if (showNullUser) {
- nullUser = {
- name: 'Unassigned',
- id: 0
- };
- data.results.unshift(nullUser);
- }
- if (showAnyUser) {
- name = showAnyUser;
- if (name === true) {
- name = 'Any User';
- }
- anyUser = {
- name: name,
- id: null
- };
- data.results.unshift(anyUser);
- }
- }
- if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) {
- var trimmed = query.term.trim();
- emailUser = {
- name: "Invite \"" + query.term + "\"",
- username: trimmed,
- id: trimmed
- };
- data.results.unshift(emailUser);
- }
- return query.callback(data);
- });
- },
- initSelection: function() {
- var args;
- args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
- return _this.initSelection.apply(_this, args);
- },
- formatResult: function() {
- var args;
- args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
- return _this.formatResult.apply(_this, args);
- },
- formatSelection: function() {
- var args;
- args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
- return _this.formatSelection.apply(_this, args);
- },
- dropdownCssClass: "ajax-users-dropdown",
- // we do not want to escape markup since we are displaying html in results
- escapeMarkup: function(m) {
- return m;
+ if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) {
+ var trimmed = query.term.trim();
+ emailUser = {
+ name: "Invite \"" + query.term + "\"",
+ username: trimmed,
+ id: trimmed
+ };
+ data.results.unshift(emailUser);
}
+ return query.callback(data);
});
- };
- })(this));
- }
-
- UsersSelect.prototype.initSelection = function(element, callback) {
- var id, nullUser;
- id = $(element).val();
- if (id === "0") {
- nullUser = {
- name: 'Unassigned'
- };
- return callback(nullUser);
- } else if (id !== "") {
- return this.user(id, callback);
- }
- };
-
- UsersSelect.prototype.formatResult = function(user) {
- var avatar;
- if (user.avatar_url) {
- avatar = user.avatar_url;
- } else {
- avatar = gon.default_avatar_url;
- }
- return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar s24' src='" + avatar + "'></div> <div class='user-name'>" + user.name + "</div> <div class='user-username'>" + (user.username || "") + "</div> </div>";
- };
-
- UsersSelect.prototype.formatSelection = function(user) {
- return user.name;
- };
-
- UsersSelect.prototype.user = function(user_id, callback) {
- if (!/^\d+$/.test(user_id)) {
- return false;
- }
-
- var url;
- url = this.buildUrl(this.userPath);
- url = url.replace(':id', user_id);
- return $.ajax({
- url: url,
- dataType: "json"
- }).done(function(user) {
- return callback(user);
- });
- };
-
- // Return users list. Filtered by query
- // Only active users retrieved
- UsersSelect.prototype.users = function(query, options, callback) {
- var url;
- url = this.buildUrl(this.usersPath);
- return $.ajax({
- url: url,
- data: {
- search: query,
- per_page: 20,
- active: true,
- project_id: options.projectId || null,
- group_id: options.groupId || null,
- skip_ldap: options.skipLdap || null,
- todo_filter: options.todoFilter || null,
- todo_state_filter: options.todoStateFilter || null,
- current_user: options.showCurrentUser || null,
- push_code_to_protected_branches: options.pushCodeToProtectedBranches || null,
- author_id: options.authorId || null,
- skip_users: options.skipUsers || null
},
- dataType: "json"
- }).done(function(users) {
- return callback(users);
+ initSelection: function() {
+ var args;
+ args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return _this.initSelection.apply(_this, args);
+ },
+ formatResult: function() {
+ var args;
+ args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return _this.formatResult.apply(_this, args);
+ },
+ formatSelection: function() {
+ var args;
+ args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return _this.formatSelection.apply(_this, args);
+ },
+ dropdownCssClass: "ajax-users-dropdown",
+ // we do not want to escape markup since we are displaying html in results
+ escapeMarkup: function(m) {
+ return m;
+ }
});
};
-
- UsersSelect.prototype.buildUrl = function(url) {
- if (gon.relative_url_root != null) {
- url = gon.relative_url_root.replace(/\/$/, '') + url;
- }
- return url;
+ })(this));
+}
+
+UsersSelect.prototype.initSelection = function(element, callback) {
+ var id, nullUser;
+ id = $(element).val();
+ if (id === "0") {
+ nullUser = {
+ name: 'Unassigned'
};
-
- return UsersSelect;
- })();
-}).call(window);
+ return callback(nullUser);
+ } else if (id !== "") {
+ return this.user(id, callback);
+ }
+};
+
+UsersSelect.prototype.formatResult = function(user) {
+ var avatar;
+ if (user.avatar_url) {
+ avatar = user.avatar_url;
+ } else {
+ avatar = gon.default_avatar_url;
+ }
+ return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar s24' src='" + avatar + "'></div> <div class='user-name'>" + user.name + "</div> <div class='user-username'>" + (user.username || "") + "</div> </div>";
+};
+
+UsersSelect.prototype.formatSelection = function(user) {
+ return user.name;
+};
+
+UsersSelect.prototype.user = function(user_id, callback) {
+ if (!/^\d+$/.test(user_id)) {
+ return false;
+ }
+
+ var url;
+ url = this.buildUrl(this.userPath);
+ url = url.replace(':id', user_id);
+ return $.ajax({
+ url: url,
+ dataType: "json"
+ }).done(function(user) {
+ return callback(user);
+ });
+};
+
+// Return users list. Filtered by query
+// Only active users retrieved
+UsersSelect.prototype.users = function(query, options, callback) {
+ var url;
+ url = this.buildUrl(this.usersPath);
+ return $.ajax({
+ url: url,
+ data: {
+ search: query,
+ per_page: options.perPage || 20,
+ active: true,
+ project_id: options.projectId || null,
+ group_id: options.groupId || null,
+ skip_ldap: options.skipLdap || null,
+ todo_filter: options.todoFilter || null,
+ todo_state_filter: options.todoStateFilter || null,
+ current_user: options.showCurrentUser || null,
+ push_code_to_protected_branches: options.pushCodeToProtectedBranches || null,
+ author_id: options.authorId || null,
+ skip_users: options.skipUsers || null
+ },
+ dataType: "json"
+ }).done(function(users) {
+ return callback(users);
+ });
+};
+
+UsersSelect.prototype.buildUrl = function(url) {
+ if (gon.relative_url_root != null) {
+ url = gon.relative_url_root.replace(/\/$/, '') + url;
+ }
+ return url;
+};
+
+export default UsersSelect;
diff --git a/app/assets/javascripts/version_check_image.js b/app/assets/javascripts/version_check_image.js
index d4f716acb72..88ba991af47 100644
--- a/app/assets/javascripts/version_check_image.js
+++ b/app/assets/javascripts/version_check_image.js
@@ -1,4 +1,4 @@
-class VersionCheckImage {
+export default class VersionCheckImage {
static bindErrorEvent(imageElement) {
imageElement.off('error').on('error', () => imageElement.hide());
}
@@ -6,5 +6,3 @@ class VersionCheckImage {
window.gl = window.gl || {};
gl.VersionCheckImage = VersionCheckImage;
-
-module.exports = VersionCheckImage;
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js
index cfd34970f11..f8b3fb748ae 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js
@@ -1,4 +1,4 @@
-require('../../lib/utils/text_utility');
+import '../../lib/utils/text_utility';
export default {
name: 'MRWidgetHeader',
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js
index 486b13e60af..8155218681c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js
@@ -1,4 +1,6 @@
import statusCodes from '~/lib/utils/http_status';
+import { bytesToMiB } from '~/lib/utils/number_utils';
+
import MemoryGraph from '../../vue_shared/components/memory_graph';
import MRWidgetService from '../services/mr_widget_service';
@@ -9,8 +11,8 @@ export default {
},
data() {
return {
- // memoryFrom: 0,
- // memoryTo: 0,
+ memoryFrom: 0,
+ memoryTo: 0,
memoryMetrics: [],
deploymentTime: 0,
hasMetrics: false,
@@ -35,18 +37,38 @@ export default {
shouldShowMetricsUnavailable() {
return !this.loadingMetrics && !this.hasMetrics && !this.loadFailed;
},
+ memoryChangeType() {
+ const memoryTo = Number(this.memoryTo);
+ const memoryFrom = Number(this.memoryFrom);
+
+ if (memoryTo > memoryFrom) {
+ return 'increased';
+ } else if (memoryTo < memoryFrom) {
+ return 'decreased';
+ }
+
+ return 'unchanged';
+ },
},
methods: {
+ getMegabytes(bytesString) {
+ const valueInBytes = Number(bytesString).toFixed(2);
+ return (bytesToMiB(valueInBytes)).toFixed(2);
+ },
computeGraphData(metrics, deploymentTime) {
this.loadingMetrics = false;
- const { memory_values } = metrics;
- // if (memory_previous.length > 0) {
- // this.memoryFrom = Number(memory_previous[0].value[1]).toFixed(2);
- // }
- //
- // if (memory_current.length > 0) {
- // this.memoryTo = Number(memory_current[0].value[1]).toFixed(2);
- // }
+ const { memory_before, memory_after, memory_values } = metrics;
+
+ // Both `memory_before` and `memory_after` objects
+ // have peculiar structure where accessing only a specific
+ // index yeilds correct value that we can use to show memory delta.
+ if (memory_before.length > 0) {
+ this.memoryFrom = this.getMegabytes(memory_before[0].value[1]);
+ }
+
+ if (memory_after.length > 0) {
+ this.memoryTo = this.getMegabytes(memory_after[0].value[1]);
+ }
if (memory_values.length > 0) {
this.hasMetrics = true;
@@ -102,7 +124,7 @@ export default {
<p
v-if="shouldShowMemoryGraph"
class="usage-info js-usage-info">
- Deployment memory usage:
+ Memory usage <b>{{memoryChangeType}}</b> from {{memoryFrom}}MB to {{memoryTo}}MB
</p>
<p
v-if="shouldShowLoadFailure"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js
index 281b74f2701..c02e10128e2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js
@@ -54,6 +54,9 @@ export default {
:href="mr.pipeline.path"
class="pipeline-id">#{{mr.pipeline.id}}</a>
{{mr.pipeline.details.status.label}}
+ </span>
+ <span
+ v-if="mr.pipeline.details.stages.length > 0">
with {{stageText}}
</span>
<div class="mr-widget-pipeline-graph">
@@ -70,7 +73,7 @@ export default {
for
<a
:href="mr.pipeline.commit.commit_path"
- class="monospace js-commit-link">
+ class="commit-sha js-commit-link">
{{mr.pipeline.commit.short_id}}</a>.
</span>
<span
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js
index 8c4535f1337..375a382615a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js
@@ -1,17 +1,42 @@
+import emptyStateSVG from 'icons/_mr_widget_empty_state.svg';
+
export default {
name: 'MRWidgetNothingToMerge',
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return { emptyStateSVG };
+ },
template: `
- <div class="mr-widget-body">
- <button
- type="button"
- class="btn btn-success btn-small"
- disabled="true">
- Merge
- </button>
- <span class="bold">
- There is nothing to merge from source branch into target branch.
- Please push new commits or use a different branch.
- </span>
+ <div class="mr-widget-body empty-state">
+ <div class="row">
+ <div class="artwork col-sm-5 col-sm-push-7 col-xs-12 text-center">
+ <span v-html="emptyStateSVG"></span>
+ </div>
+ <div class="text col-sm-7 col-sm-pull-5 col-xs-12">
+ <span>
+ Merge requests are a place to propose changes you have made to a project
+ and discuss those changes with others.
+ </span>
+ <p>
+ Interested parties can even contribute by pushing commits if they want to.
+ </p>
+ <p>
+ Currently there are no changes in this merge request's source branch.
+ Please push new commits or use a different branch.
+ </p>
+ <a
+ v-if="mr.newBlobPath"
+ :href="mr.newBlobPath"
+ class="btn btn-inverted btn-save">
+ Create file
+ </a>
+ </div>
+ </div>
</div>
`,
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js
index d866d4e94b0..fcd4fdaf09f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js
@@ -13,7 +13,7 @@ export default {
},
data() {
return {
- removeSourceBranch: true,
+ removeSourceBranch: this.mr.shouldRemoveSourceBranch,
mergeWhenBuildSucceeds: false,
useCommitMessageWithDescription: false,
setToMergeWhenPipelineSucceeds: false,
@@ -69,6 +69,9 @@ export default {
|| this.isMakingRequest
|| this.mr.preventMerge);
},
+ isRemoveSourceBranchButtonDisabled() {
+ return this.isMergeButtonDisabled || !this.mr.canRemoveSourceBranch;
+ },
shouldShowSquashBeforeMerge() {
const { commitsCount, enableSquashBeforeMerge } = this.mr;
return enableSquashBeforeMerge && commitsCount > 1;
@@ -252,8 +255,9 @@ export default {
<template v-if="isMergeAllowed()">
<label class="spacing">
<input
+ id="remove-source-branch-input"
v-model="removeSourceBranch"
- :disabled="isMergeButtonDisabled"
+ :disabled="isRemoveSourceBranchButtonDisabled"
type="checkbox"/> Remove source branch
</label>
diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
index bfe30ee4c08..fe5e1bbb55c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js
+++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
@@ -41,3 +41,4 @@ export { default as getStateKey } from './stores/get_state_key';
export { default as mrWidgetOptions } from './mr_widget_options';
export { default as stateMaps } from './stores/state_maps';
export { default as SquashBeforeMerge } from './components/states/mr_widget_squash_before_merge';
+export { default as notify } from '../lib/utils/notify';
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index cd65ac069c5..43ef468c303 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -4,6 +4,8 @@ import {
} from './dependencies';
document.addEventListener('DOMContentLoaded', () => {
+ gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
+
const vm = new Vue(mrWidgetOptions);
window.gl.mrWidget = {
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js
index 99600b6664e..2339a00ddd0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js
@@ -29,6 +29,7 @@ import {
eventHub,
stateMaps,
SquashBeforeMerge,
+ notify,
} from './dependencies';
export default {
@@ -77,8 +78,10 @@ export default {
this.service.checkStatus()
.then(res => res.json())
.then((res) => {
+ this.handleNotification(res);
this.mr.setData(res);
this.setFavicon();
+
if (cb) {
cb.call(null, res);
}
@@ -136,6 +139,15 @@ export default {
new Flash('Something went wrong. Please try again.'); // eslint-disable-line
});
},
+ handleNotification(data) {
+ if (data.ci_status === this.mr.ciStatus) return;
+
+ const label = data.pipeline.details.status.label;
+ const title = `Pipeline ${label}`;
+ const message = `Pipeline ${label} for "${data.title}"`;
+
+ notify.notifyMe(title, message, this.mr.gitlabLogo);
+ },
resumePolling() {
this.pollingInterval.resume();
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index 1533c857863..69bc1436284 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -5,6 +5,8 @@ export default class MergeRequestStore {
constructor(data) {
this.sha = data.diff_head_sha;
+ this.gitlabLogo = data.gitlabLogo;
+
this.setData(data);
}
@@ -50,13 +52,14 @@ export default class MergeRequestStore {
this.cancelAutoMergePath = data.cancel_merge_when_pipeline_succeeds_path;
this.removeWIPPath = data.remove_wip_path;
this.sourceBranchRemoved = !data.source_branch_exists;
- this.shouldRemoveSourceBranch = (data.merge_params || {}).should_remove_source_branch || false;
+ this.shouldRemoveSourceBranch = data.remove_source_branch || false;
this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false;
this.mergeWhenPipelineSucceeds = data.merge_when_pipeline_succeeds || false;
this.mergePath = data.merge_path;
this.statusPath = data.status_path;
this.emailPatchesPath = data.email_patches_path;
this.plainDiffPath = data.plain_diff_path;
+ this.newBlobPath = data.new_blob_path;
this.createIssueToResolveDiscussionsPath = data.create_issue_to_resolve_discussions_path;
this.mergeCheckPath = data.merge_check_path;
this.mergeActionsContentPath = data.commit_change_content_path;
diff --git a/app/assets/javascripts/vue_shared/ci_action_icons.js b/app/assets/javascripts/vue_shared/ci_action_icons.js
index ee41dc95beb..b21f0ab49fd 100644
--- a/app/assets/javascripts/vue_shared/ci_action_icons.js
+++ b/app/assets/javascripts/vue_shared/ci_action_icons.js
@@ -3,24 +3,19 @@ import retrySVG from 'icons/_icon_action_retry.svg';
import playSVG from 'icons/_icon_action_play.svg';
import stopSVG from 'icons/_icon_action_stop.svg';
+/**
+ * For the provided action returns the respective SVG
+ *
+ * @param {String} action
+ * @return {SVG|String}
+ */
export default function getActionIcon(action) {
- let icon;
- switch (action) {
- case 'icon_action_cancel':
- icon = cancelSVG;
- break;
- case 'icon_action_retry':
- icon = retrySVG;
- break;
- case 'icon_action_play':
- icon = playSVG;
- break;
- case 'icon_action_stop':
- icon = stopSVG;
- break;
- default:
- icon = '';
- }
+ const icons = {
+ icon_action_cancel: cancelSVG,
+ icon_action_play: playSVG,
+ icon_action_retry: retrySVG,
+ icon_action_stop: stopSVG,
+ };
- return icon;
+ return icons[action] || '';
}
diff --git a/app/assets/javascripts/vue_shared/components/commit.js b/app/assets/javascripts/vue_shared/components/commit.js
index fb68abd95a2..ff5ae28e062 100644
--- a/app/assets/javascripts/vue_shared/components/commit.js
+++ b/app/assets/javascripts/vue_shared/components/commit.js
@@ -1,4 +1,5 @@
import commitIconSvg from 'icons/_icon_commit.svg';
+import userAvatarLink from './user_avatar/user_avatar_link.vue';
export default {
props: {
@@ -90,7 +91,7 @@ export default {
hasAuthor() {
return this.author &&
this.author.avatar_url &&
- this.author.web_url &&
+ this.author.path &&
this.author.username;
},
@@ -110,6 +111,9 @@ export default {
return { commitIconSvg };
},
+ components: {
+ userAvatarLink,
+ },
template: `
<div class="branch-commit">
@@ -119,30 +123,28 @@ export default {
</div>
<a v-if="hasCommitRef"
- class="monospace branch-name"
+ class="ref-name"
:href="commitRef.ref_url">
{{commitRef.name}}
</a>
<div v-html="commitIconSvg" class="commit-icon js-commit-icon"></div>
- <a class="commit-id monospace"
+ <a class="commit-sha"
:href="commitUrl">
{{shortSha}}
</a>
- <p class="commit-title">
- <span v-if="title">
- <a v-if="hasAuthor"
+ <div class="commit-title flex-truncate-parent">
+ <span v-if="title" class="flex-truncate-child">
+ <user-avatar-link
+ v-if="hasAuthor"
class="avatar-image-container"
- :href="author.web_url">
- <img
- class="avatar has-tooltip s20"
- :src="author.avatar_url"
- :alt="userImageAltDescription"
- :title="author.username" />
- </a>
-
+ :link-href="author.path"
+ :img-src="author.avatar_url"
+ :img-alt="userImageAltDescription"
+ :tooltip-text="author.username"
+ />
<a class="commit-row-message"
:href="commitUrl">
{{title}}
@@ -151,7 +153,7 @@ export default {
<span v-else>
Cant find HEAD commit for this branch
</span>
- </p>
+ </div>
</div>
`,
};
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
new file mode 100644
index 00000000000..fe6d6a792e7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -0,0 +1,133 @@
+<script>
+import ciIconBadge from './ci_badge_link.vue';
+import loadingIcon from './loading_icon.vue';
+import timeagoTooltip from './time_ago_tooltip.vue';
+import tooltipMixin from '../mixins/tooltip';
+import userAvatarImage from './user_avatar/user_avatar_image.vue';
+
+/**
+ * Renders header component for job and pipeline page based on UI mockups
+ *
+ * Used in:
+ * - job show page
+ * - pipeline show page
+ */
+export default {
+ props: {
+ status: {
+ type: Object,
+ required: true,
+ },
+ itemName: {
+ type: String,
+ required: true,
+ },
+ itemId: {
+ type: Number,
+ required: true,
+ },
+ time: {
+ type: String,
+ required: true,
+ },
+ user: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ actions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+
+ mixins: [
+ tooltipMixin,
+ ],
+
+ components: {
+ ciIconBadge,
+ loadingIcon,
+ timeagoTooltip,
+ userAvatarImage,
+ },
+
+ computed: {
+ userAvatarAltText() {
+ return `${this.user.name}'s avatar`;
+ },
+ },
+
+ methods: {
+ onClickAction(action) {
+ this.$emit('actionClicked', action);
+ },
+ },
+};
+</script>
+<template>
+ <header class="page-content-header">
+ <section class="header-main-content">
+
+ <ci-icon-badge :status="status" />
+
+ <strong>
+ {{itemName}} #{{itemId}}
+ </strong>
+
+ triggered
+
+ <timeago-tooltip :time="time" />
+
+ by
+
+ <template v-if="user">
+ <a
+ :href="user.path"
+ :title="user.email"
+ class="js-user-link commit-committer-link"
+ ref="tooltip">
+
+ <user-avatar-image
+ :img-src="user.avatar_url"
+ :img-alt="userAvatarAltText"
+ :tooltip-text="user.name"
+ :img-size="24"
+ />
+
+ {{user.name}}
+ </a>
+ </template>
+ </section>
+
+ <section
+ class="header-action-button nav-controls"
+ v-if="actions.length">
+ <template
+ v-for="action in actions">
+ <a
+ v-if="action.type === 'link'"
+ :href="action.path"
+ :class="action.cssClass">
+ {{action.label}}
+ </a>
+
+ <button
+ v-else="action.type === 'button'"
+ @click="onClickAction(action)"
+ :disabled="action.isLoading"
+ :class="action.cssClass"
+ type="button">
+ {{action.label}}
+
+ <i
+ v-show="action.isLoading"
+ class="fa fa-spin fa-spinner"
+ aria-hidden="true">
+ </i>
+ </button>
+ </template>
+ </section>
+ </header>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/loading_icon.vue b/app/assets/javascripts/vue_shared/components/loading_icon.vue
new file mode 100644
index 00000000000..41b1d0165b0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/loading_icon.vue
@@ -0,0 +1,33 @@
+<script>
+ export default {
+ props: {
+ label: {
+ type: String,
+ required: false,
+ default: 'Loading',
+ },
+
+ size: {
+ type: String,
+ required: false,
+ default: '1',
+ },
+ },
+
+ computed: {
+ cssClass() {
+ return `fa-${this.size}x`;
+ },
+ },
+ };
+</script>
+<template>
+ <div class="text-center">
+ <i
+ class="fa fa-spin fa-spinner"
+ :class="cssClass"
+ aria-hidden="true"
+ :aria-label="label">
+ </i>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
new file mode 100644
index 00000000000..e6977681e96
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -0,0 +1,107 @@
+<script>
+ /* global Flash */
+ import markdownHeader from './header.vue';
+ import markdownToolbar from './toolbar.vue';
+
+ export default {
+ props: {
+ markdownPreviewUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ markdownPreview: '',
+ markdownPreviewLoading: false,
+ previewMarkdown: false,
+ };
+ },
+ components: {
+ markdownHeader,
+ markdownToolbar,
+ },
+ methods: {
+ toggleMarkdownPreview() {
+ this.previewMarkdown = !this.previewMarkdown;
+
+ if (!this.previewMarkdown) {
+ this.markdownPreview = '';
+ } else {
+ this.markdownPreviewLoading = true;
+ this.$http.post(
+ this.markdownPreviewUrl,
+ {
+ /*
+ Can't use `$refs` as the component is technically in the parent component
+ so we access the VNode & then get the element
+ */
+ text: this.$slots.textarea[0].elm.value,
+ },
+ )
+ .then((res) => {
+ const data = res.json();
+
+ this.markdownPreviewLoading = false;
+ this.markdownPreview = data.body;
+
+ this.$nextTick(() => {
+ $(this.$refs['markdown-preview']).renderGFM();
+ });
+ })
+ .catch(() => new Flash('Error loading markdown preview'));
+ }
+ },
+ },
+ mounted() {
+ /*
+ GLForm class handles all the toolbar buttons
+ */
+ return new gl.GLForm($(this.$refs['gl-form']), true);
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="md-area prepend-top-default append-bottom-default js-vue-markdown-field"
+ ref="gl-form">
+ <markdown-header
+ :preview-markdown="previewMarkdown"
+ @toggle-markdown="toggleMarkdownPreview" />
+ <div
+ class="md-write-holder"
+ v-show="!previewMarkdown">
+ <div class="zen-backdrop">
+ <slot name="textarea"></slot>
+ <a
+ class="zen-control zen-control-leave js-zen-leave"
+ href="#"
+ aria-label="Enter zen mode">
+ <i
+ class="fa fa-compress"
+ aria-hidden="true">
+ </i>
+ </a>
+ <markdown-toolbar
+ :markdown-docs="markdownDocs" />
+ </div>
+ </div>
+ <div
+ class="md md-preview-holder md-preview"
+ v-show="previewMarkdown">
+ <div
+ ref="markdown-preview"
+ v-html="markdownPreview">
+ </div>
+ <span v-if="markdownPreviewLoading">
+ Loading...
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
new file mode 100644
index 00000000000..1a11f493b7f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -0,0 +1,113 @@
+<script>
+ import tooltipMixin from '../../mixins/tooltip';
+ import toolbarButton from './toolbar_button.vue';
+
+ export default {
+ mixins: [
+ tooltipMixin,
+ ],
+ props: {
+ previewMarkdown: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ components: {
+ toolbarButton,
+ },
+ methods: {
+ toggleMarkdownPreview(e, form) {
+ if (form && !form.find('.js-vue-markdown-field').length) {
+ return;
+ } else if (e.target.blur) {
+ e.target.blur();
+ }
+
+ this.$emit('toggle-markdown');
+ },
+ },
+ mounted() {
+ $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview);
+ $(document).on('markdown-preview:hide.vue', this.toggleMarkdownPreview);
+ },
+ beforeDestroy() {
+ $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview);
+ $(document).off('markdown-preview:hide.vue', this.toggleMarkdownPreview);
+ },
+ };
+</script>
+
+<template>
+ <div class="md-header">
+ <ul class="nav-links clearfix">
+ <li :class="{ active: !previewMarkdown }">
+ <a
+ href="#md-write-holder"
+ tabindex="-1"
+ @click.prevent="toggleMarkdownPreview($event)">
+ Write
+ </a>
+ </li>
+ <li :class="{ active: previewMarkdown }">
+ <a
+ href="#md-preview-holder"
+ tabindex="-1"
+ @click.prevent="toggleMarkdownPreview($event)">
+ Preview
+ </a>
+ </li>
+ <li class="pull-right">
+ <div class="toolbar-group">
+ <toolbar-button
+ tag="**"
+ button-title="Add bold text"
+ icon="bold" />
+ <toolbar-button
+ tag="*"
+ button-title="Add italic text"
+ icon="italic" />
+ <toolbar-button
+ tag="> "
+ :prepend="true"
+ button-title="Insert a quote"
+ icon="quote-right" />
+ <toolbar-button
+ tag="`"
+ tag-block="```"
+ button-title="Insert code"
+ icon="code" />
+ <toolbar-button
+ tag="* "
+ :prepend="true"
+ button-title="Add a bullet list"
+ icon="list-ul" />
+ <toolbar-button
+ tag="1. "
+ :prepend="true"
+ button-title="Add a numbered list"
+ icon="list-ol" />
+ <toolbar-button
+ tag="* [ ] "
+ :prepend="true"
+ button-title="Add a task list"
+ icon="check-square-o" />
+ </div>
+ <div class="toolbar-group">
+ <button
+ aria-label="Go full screen"
+ class="toolbar-btn js-zen-enter"
+ data-container="body"
+ tabindex="-1"
+ title="Go full screen"
+ type="button"
+ ref="tooltip">
+ <i
+ aria-hidden="true"
+ class="fa fa-arrows-alt fa-fw">
+ </i>
+ </button>
+ </div>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
new file mode 100644
index 00000000000..93252293ba6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -0,0 +1,33 @@
+<script>
+ export default {
+ props: {
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="comment-toolbar clearfix">
+ <div class="toolbar-text">
+ <a
+ :href="markdownDocs"
+ target="_blank"
+ tabindex="-1">
+ Markdown is supported
+ </a>
+ </div>
+ <button
+ class="toolbar-button markdown-selector"
+ type="button"
+ tabindex="-1">
+ <i
+ class="fa fa-file-image-o toolbar-button-icon"
+ aria-hidden="true">
+ </i>
+ Attach a file
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
new file mode 100644
index 00000000000..096be507625
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -0,0 +1,58 @@
+<script>
+ import tooltipMixin from '../../mixins/tooltip';
+
+ export default {
+ mixins: [
+ tooltipMixin,
+ ],
+ props: {
+ buttonTitle: {
+ type: String,
+ required: true,
+ },
+ icon: {
+ type: String,
+ required: true,
+ },
+ tag: {
+ type: String,
+ required: true,
+ },
+ tagBlock: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ prepend: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ iconClass() {
+ return `fa-${this.icon}`;
+ },
+ },
+ };
+</script>
+
+<template>
+ <button
+ type="button"
+ class="toolbar-btn js-md hidden-xs"
+ tabindex="-1"
+ ref="tooltip"
+ data-container="body"
+ :data-md-tag="tag"
+ :data-md-block="tagBlock"
+ :data-md-prepend="prepend"
+ :title="buttonTitle"
+ :aria-label="buttonTitle">
+ <i
+ aria-hidden="true"
+ class="fa fa-fw"
+ :class="iconClass">
+ </i>
+ </button>
+</template>
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 73e70766494..f60f8eeb43d 100644
--- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
+++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
@@ -4,7 +4,7 @@ import PipelinesActionsComponent from '../../pipelines/components/pipelines_acti
import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts';
import ciBadge from './ci_badge_link.vue';
import PipelinesStageComponent from '../../pipelines/components/stage.vue';
-import PipelinesUrlComponent from '../../pipelines/components/pipeline_url';
+import PipelinesUrlComponent from '../../pipelines/components/pipeline_url.vue';
import PipelinesTimeagoComponent from '../../pipelines/components/time_ago';
import CommitComponent from './commit';
@@ -62,10 +62,12 @@ export default {
commitAuthor() {
let commitAuthorInformation;
+ if (!this.pipeline || !this.pipeline.commit) {
+ return null;
+ }
+
// 1. person who is an author of a commit might be a GitLab user
- if (this.pipeline &&
- this.pipeline.commit &&
- this.pipeline.commit.author) {
+ if (this.pipeline.commit.author) {
// 2. if person who is an author of a commit is a GitLab user
// he/she can have a GitLab avatar
if (this.pipeline.commit.author.avatar_url) {
@@ -77,14 +79,11 @@ export default {
avatar_url: this.pipeline.commit.author_gravatar_url,
});
}
- }
-
- // 4. If committer is not a GitLab User he/she can have a Gravatar
- if (this.pipeline &&
- this.pipeline.commit) {
+ // 4. If committer is not a GitLab User he/she can have a Gravatar
+ } else {
commitAuthorInformation = {
avatar_url: this.pipeline.commit.author_gravatar_url,
- web_url: `mailto:${this.pipeline.commit.author_email}`,
+ path: `mailto:${this.pipeline.commit.author_email}`,
username: this.pipeline.commit.author_name,
};
}
diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.js b/app/assets/javascripts/vue_shared/components/table_pagination.vue
index ebb14912b00..5e7df22dd83 100644
--- a/app/assets/javascripts/vue_shared/components/table_pagination.js
+++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue
@@ -1,3 +1,4 @@
+<script>
const PAGINATION_UI_BUTTON_LIMIT = 4;
const UI_LIMIT = 6;
const SPREAD = '...';
@@ -114,22 +115,23 @@ export default {
return items;
},
},
- template: `
- <div class="gl-pagination">
- <ul class="pagination clearfix">
- <li v-for='item in getItems'
- :class='{
- page: item.page,
- prev: item.prev,
- next: item.next,
- separator: item.separator,
- active: item.active,
- disabled: item.disabled
- }'
- >
- <a @click="changePage($event)">{{item.title}}</a>
- </li>
- </ul>
- </div>
- `,
};
+</script>
+<template>
+ <div class="gl-pagination">
+ <ul class="pagination clearfix">
+ <li
+ v-for="item in getItems"
+ :class="{
+ page: item.page,
+ prev: item.prev,
+ next: item.next,
+ separator: item.separator,
+ active: item.active,
+ disabled: item.disabled
+ }">
+ <a @click="changePage($event)">{{item.title}}</a>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
index 934e7e8eacb..af2b4c6786e 100644
--- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
+++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
@@ -1,5 +1,6 @@
<script>
import tooltipMixin from '../mixins/tooltip';
+import timeagoMixin from '../mixins/timeago';
import '../../lib/utils/datetime_utility';
/**
@@ -25,42 +26,33 @@ export default {
default: false,
},
- htmlClass: {
+ cssClass: {
type: String,
required: false,
default: '',
},
},
- mixins: [tooltipMixin],
+ mixins: [
+ tooltipMixin,
+ timeagoMixin,
+ ],
computed: {
- cssClass() {
+ timeagoCssClass() {
return this.shortFormat ? 'js-short-timeago' : 'js-timeago';
},
-
- tooltipTitle() {
- return gl.utils.formatDate(this.time);
- },
-
- timeFormated() {
- const timeago = gl.utils.getTimeago();
-
- return timeago.format(this.time);
- },
},
};
</script>
-
<template>
<time
- :class="[cssClass, htmlClass]"
+ :class="[timeagoCssClass, cssClass]"
class="js-timeago js-timeago-render"
- :title="tooltipTitle"
+ :title="tooltipTitle(time)"
:data-placement="tooltipPlacement"
data-container="body"
- ref="tooltip"
- >
- {{timeFormated}}
+ ref="tooltip">
+ {{timeFormated(time)}}
</time>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
new file mode 100644
index 00000000000..cd6f8c7aee4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -0,0 +1,86 @@
+<script>
+
+/* This is a re-usable vue component for rendering a user avatar that
+ does not need to link to the user's profile. The image and an optional
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-image
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :tooltip-text="tooltipText"
+ tooltip-placement="top"
+ />
+
+*/
+
+import defaultAvatarUrl from 'images/no_avatar.png';
+import TooltipMixin from '../../mixins/tooltip';
+
+export default {
+ name: 'UserAvatarImage',
+ mixins: [TooltipMixin],
+ props: {
+ imgSrc: {
+ type: String,
+ required: false,
+ default: defaultAvatarUrl,
+ },
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: 'user avatar',
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ },
+ computed: {
+ tooltipContainer() {
+ return this.tooltipText ? 'body' : null;
+ },
+ avatarSizeClass() {
+ return `s${this.size}`;
+ },
+ // API response sends null when gravatar is disabled and
+ // we provide an empty string when we use it inside user avatar link.
+ // In both cases we should render the defaultAvatarUrl
+ imageSource() {
+ return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
+ },
+ },
+};
+</script>
+
+<template>
+ <img
+ class="avatar"
+ :class="[avatarSizeClass, cssClasses]"
+ :src="imageSource"
+ :width="size"
+ :height="size"
+ :alt="imgAlt"
+ :data-container="tooltipContainer"
+ :data-placement="tooltipPlacement"
+ :title="tooltipText"
+ ref="tooltip"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
new file mode 100644
index 00000000000..95898d54cf7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
@@ -0,0 +1,80 @@
+<script>
+
+/* This is a re-usable vue component for rendering a user avatar wrapped in
+ a clickable link (likely to the user's profile). The link, image, and
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-link
+ :link-href="userProfileUrl"
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :img-size="20"
+ :tooltip-text="tooltipText"
+ tooltip-placement="top"
+ />
+
+*/
+
+import userAvatarImage from './user_avatar_image.vue';
+
+export default {
+ name: 'UserAvatarLink',
+ components: {
+ userAvatarImage,
+ },
+ props: {
+ linkHref: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSrc: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgCssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSize: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ },
+};
+</script>
+
+<template>
+ <a
+ class="user-avatar-link"
+ :href="linkHref">
+ <user-avatar-image
+ :img-src="imgSrc"
+ :img-alt="imgAlt"
+ :css-classes="imgCssClasses"
+ :size="imgSize"
+ :tooltip-text="tooltipText"
+ :tooltip-placement="tooltipPlacement"
+ />
+ </a>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue
new file mode 100644
index 00000000000..d2ff2ac006e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue
@@ -0,0 +1,45 @@
+<script>
+
+/* This is a re-usable vue component for rendering a user avatar svg (typically
+ for a blank state). It will receive styles comparable to the user avatar,
+ but no image is loaded, it isn't wrapped in a link, and tooltips aren't supported.
+ The svg and avatar size can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-svg
+ :svg="potentialApproverSvg"
+ :size="20"
+ />
+
+*/
+
+export default {
+ props: {
+ svg: {
+ type: String,
+ required: true,
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ },
+ computed: {
+ avatarSizeClass() {
+ return `s${this.size}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <svg
+ :class="avatarSizeClass"
+ :height="size"
+ :width="size"
+ v-html="svg">
+ </svg>
+</template>
+
diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js
new file mode 100644
index 00000000000..20f63ab663c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/mixins/timeago.js
@@ -0,0 +1,18 @@
+import '../../lib/utils/datetime_utility';
+
+/**
+ * Mixin with time ago methods used in some vue components
+ */
+export default {
+ methods: {
+ timeFormated(time) {
+ const timeago = gl.utils.getTimeago();
+
+ return timeago.format(time);
+ },
+
+ tooltipTitle(time) {
+ return gl.utils.formatDate(time);
+ },
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/mixins/tooltip.js b/app/assets/javascripts/vue_shared/mixins/tooltip.js
index 9bb948bff66..995c0c98505 100644
--- a/app/assets/javascripts/vue_shared/mixins/tooltip.js
+++ b/app/assets/javascripts/vue_shared/mixins/tooltip.js
@@ -6,4 +6,8 @@ export default {
updated() {
$(this.$refs.tooltip).tooltip('fixTitle');
},
+
+ beforeDestroy() {
+ $(this.$refs.tooltip).tooltip('destroy');
+ },
};
diff --git a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
index d5f87588c28..740930dce5b 100644
--- a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
+++ b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
@@ -4,7 +4,7 @@ import VueResource from 'vue-resource';
Vue.use(VueResource);
// Maintain a global counter for active requests
-// see: spec/support/wait_for_vue_resource.rb
+// see: spec/support/wait_for_requests.rb
Vue.http.interceptors.push((request, next) => {
window.activeVueResources = window.activeVueResources || 0;
window.activeVueResources += 1;
diff --git a/app/assets/javascripts/wikis.js b/app/assets/javascripts/wikis.js
index 75fd1394a03..4194c1bc08d 100644
--- a/app/assets/javascripts/wikis.js
+++ b/app/assets/javascripts/wikis.js
@@ -1,8 +1,8 @@
/* eslint-disable no-param-reassign */
/* global Breakpoints */
-require('./breakpoints');
-require('vendor/jquery.nicescroll');
+import 'vendor/jquery.nicescroll';
+import './breakpoints';
((global) => {
class Wikis {
diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js
index ce626cf7b46..b7fe552dec2 100644
--- a/app/assets/javascripts/zen_mode.js
+++ b/app/assets/javascripts/zen_mode.js
@@ -1,5 +1,4 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-unused-vars, consistent-return, camelcase, comma-dangle, max-len */
-/* global Dropzone */
/* global Mousetrap */
// Zen Mode (full screen) textarea
@@ -7,10 +6,12 @@
/*= provides zen_mode:enter */
/*= provides zen_mode:leave */
-require('vendor/jquery.scrollTo');
-window.Dropzone = require('dropzone');
-require('mousetrap');
-require('mousetrap/plugins/pause/mousetrap-pause');
+import 'vendor/jquery.scrollTo';
+import Dropzone from 'dropzone';
+import 'mousetrap';
+import 'mousetrap/plugins/pause/mousetrap-pause';
+
+window.Dropzone = Dropzone;
//
// ### Events
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index d2ec1791d2b..9dc9f9a9068 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -34,6 +34,7 @@
@import "framework/selects.scss";
@import "framework/sidebar.scss";
@import "framework/tables.scss";
+@import "framework/notes.scss";
@import "framework/timeline.scss";
@import "framework/typography.scss";
@import "framework/zen.scss";
@@ -48,3 +49,4 @@
@import "framework/icons.scss";
@import "framework/snippets.scss";
@import "framework/memory_graph.scss";
+@import "framework/responsive-tables.scss";
diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss
index 91c1ebd5a7d..4ae2b164d2e 100644
--- a/app/assets/stylesheets/framework/avatar.scss
+++ b/app/assets/stylesheets/framework/avatar.scss
@@ -10,6 +10,8 @@
border-radius: $avatar_radius;
border: 1px solid $avatar-border;
&.s16 { @include avatar-size(16px, 6px); }
+ &.s18 { @include avatar-size(18px, 6px); }
+ &.s19 { @include avatar-size(19px, 6px); }
&.s20 { @include avatar-size(20px, 7px); }
&.s24 { @include avatar-size(24px, 8px); }
&.s26 { @include avatar-size(26px, 8px); }
diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss
index 0db3ac1a60e..75907c35b7e 100644
--- a/app/assets/stylesheets/framework/awards.scss
+++ b/app/assets/stylesheets/framework/awards.scss
@@ -10,7 +10,7 @@
top: 0;
margin-top: 3px;
padding: $gl-padding;
- z-index: 9;
+ z-index: 300;
width: 300px;
font-size: 14px;
background-color: $white-light;
@@ -110,6 +110,7 @@
.award-control {
margin: 0 5px 6px 0;
outline: 0;
+ position: relative;
&.disabled {
cursor: default;
@@ -227,8 +228,8 @@
.award-control-icon-positive,
.award-control-icon-super-positive {
position: absolute;
- left: 11px;
- bottom: 7px;
+ left: 10px;
+ bottom: 6px;
opacity: 0;
@include transition(opacity, transform);
}
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index ac1fc0eb8ae..fefe5575d9b 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -23,7 +23,6 @@
.row-content-block {
margin-top: 0;
- margin-bottom: -$gl-padding;
background-color: $gray-light;
padding: $gl-padding;
margin-bottom: 0;
@@ -312,7 +311,7 @@
}
.empty-state {
- margin: 100px 0 0;
+ margin: 5% auto 0;
.text-content {
max-width: 460px;
@@ -335,27 +334,12 @@
}
.btn {
- margin: $btn-side-margin $btn-side-margin 0 0;
- }
-
- @media(max-width: $screen-xs-max) {
- margin-top: 50px;
- text-align: center;
+ margin: $btn-side-margin 5px;
- .btn {
+ @media(max-width: $screen-xs-max) {
width: 100%;
}
}
-
- @media(min-width: $screen-xs-max) {
- &.merge-requests .text-content {
- margin-top: 40px;
- }
-
- &.labels .text-content {
- margin-top: 70px;
- }
- }
}
.flex-container-block {
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 57387b913dc..00c981f64c5 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -445,3 +445,9 @@ table {
word-wrap: break-word;
}
}
+
+.disabled-content {
+ pointer-events: none;
+ opacity: .5;
+}
+
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 5c9b71a452c..17f1dc2f479 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -97,7 +97,7 @@
.fa-chevron-down {
font-size: $dropdown-chevron-size;
position: relative;
- top: -3px;
+ top: -2px;
margin-left: 5px;
}
@@ -201,6 +201,11 @@
width: 100%;
}
+ &.dropdown-open-left {
+ right: 0;
+ left: auto;
+ }
+
&.is-loading {
.dropdown-content {
display: none;
@@ -261,7 +266,14 @@
text-transform: capitalize;
}
- .separator + .dropdown-header {
+ .dropdown-bold-header {
+ font-weight: 600;
+ line-height: 22px;
+ padding: 0 16px;
+ }
+
+ .separator + .dropdown-header,
+ .separator + .dropdown-bold-header {
padding-top: 2px;
}
diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss
index d86ae57cd9a..2d6bc17d4ff 100644
--- a/app/assets/stylesheets/framework/emojis.scss
+++ b/app/assets/stylesheets/framework/emojis.scss
@@ -1,5 +1,4 @@
gl-emoji {
- display: inline-block;
display: inline-flex;
vertical-align: middle;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 1dd0e5ab581..d08df05fd6c 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -4,13 +4,14 @@
*/
.file-holder {
border: 1px solid $border-color;
+ border-radius: $border-radius-default;
&.file-holder-no-border {
border: 0;
}
&.readme-holder {
- margin: $gl-padding-top 0;
+ margin: $gl-padding 0;
}
table {
@@ -25,7 +26,7 @@
text-align: left;
padding: 10px $gl-padding;
word-wrap: break-word;
- border-radius: 3px 3px 0 0;
+ border-radius: $border-radius-default $border-radius-default 0 0;
&.file-title-clear {
padding-left: 0;
@@ -65,10 +66,10 @@
&.video {
background: $file-image-bg;
text-align: center;
+ padding: 30px;
img,
video {
- padding: 20px;
max-width: 80%;
}
}
@@ -84,7 +85,7 @@
}
/**
- * Blame file
+ * Annotate file
*/
&.blame {
table {
@@ -94,9 +95,16 @@
tr {
border-bottom: 1px solid $blame-border;
+
+ &:last-child {
+ border-bottom: none;
+ }
}
td {
+ border-top: none;
+ border-bottom: none;
+
&:first-child {
border-left: none;
}
@@ -107,7 +115,7 @@
}
td.blame-commit {
- padding: 0 10px;
+ padding: 5px 10px;
min-width: 400px;
background: $gray-light;
}
@@ -246,7 +254,7 @@ span.idiff {
border-bottom: 1px solid $border-color;
padding: 5px $gl-padding;
margin: 0;
- border-radius: 3px 3px 0 0;
+ border-radius: $border-radius-default $border-radius-default 0 0;
.file-header-content {
white-space: nowrap;
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index e624d0d951e..cfbaaaa04c7 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -22,12 +22,6 @@
}
@media (min-width: $screen-sm-min) {
- .issues_bulk_update {
- .dropdown-menu-toggle {
- width: 132px;
- }
- }
-
.filter-item:not(:last-child) {
margin-right: 6px;
}
@@ -90,6 +84,7 @@
.filtered-search-term {
display: -webkit-flex;
display: flex;
+ flex-shrink: 0;
margin-top: 5px;
margin-bottom: 5px;
@@ -104,6 +99,22 @@
padding: 2px 7px;
}
+ .name {
+ background-color: $filter-name-resting-color;
+ color: $filter-name-text-color;
+ border-radius: 2px 0 0 2px;
+ margin-right: 1px;
+ text-transform: capitalize;
+ }
+
+ .value-container {
+ background-color: $white-normal;
+ color: $filter-value-text-color;
+ border-radius: 0 2px 2px 0;
+ margin-right: 5px;
+ padding-right: 8px;
+ }
+
.value {
padding-right: 0;
}
@@ -111,7 +122,7 @@
.remove-token {
display: inline-block;
padding-left: 4px;
- padding-right: 8px;
+ padding-right: 0;
.fa-close {
color: $gl-text-color-secondary;
@@ -131,30 +142,17 @@
}
}
}
+}
+.filtered-search-token:hover,
+.filtered-search-token .selected,
+.filtered-search-term .selected {
.name {
- background-color: $filter-name-resting-color;
- color: $filter-name-text-color;
- border-radius: 2px 0 0 2px;
- margin-right: 1px;
- text-transform: capitalize;
+ background-color: $filter-name-selected-color;
}
.value-container {
- background-color: $white-normal;
- color: $filter-value-text-color;
- border-radius: 0 2px 2px 0;
- margin-right: 5px;
- }
-
- .selected {
- .name {
- background-color: $filter-name-selected-color;
- }
-
- .value-container {
- background-color: $filter-value-selected-color;
- }
+ background-color: $filter-value-selected-color;
}
}
@@ -238,7 +236,7 @@
width: 35px;
background-color: $white-light;
border: none;
- position: absolute;
+ position: static;
right: 0;
height: 100%;
outline: none;
@@ -263,7 +261,9 @@
}
.filtered-search-input-dropdown-menu {
+ max-height: 215px;
max-width: 280px;
+ overflow: auto;
@media (max-width: $screen-xs-min) {
width: auto;
@@ -283,17 +283,10 @@
.filtered-search-history-dropdown-toggle-button {
flex: 1;
width: auto;
- padding-right: 10px;
-
border-radius: 0;
- border-top: 0;
- border-left: 0;
- border-bottom: 0;
+ border: 0;
border-right: 1px solid $border-color;
-
color: $gl-text-color-secondary;
- line-height: 1;
-
transition: color 0.1s linear;
&:hover,
@@ -301,6 +294,17 @@
color: $gl-text-color;
border-color: $dropdown-input-focus-border;
outline: none;
+
+ svg {
+ fill: $gl-text-color;
+ }
+ }
+
+ svg {
+ height: 14px;
+ width: 14px;
+ fill: $gl-text-color-secondary;
+ vertical-align: middle;
}
.dropdown-toggle-text {
@@ -312,11 +316,6 @@
color: inherit;
}
}
-
- .fa {
- position: static;
- }
-
}
.filtered-search-history-dropdown {
@@ -373,17 +372,6 @@
padding: 0;
}
-.filter-dropdown {
- max-height: 215px;
- overflow: auto;
-}
-
-@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
- .issue-bulk-update-dropdown-toggle {
- width: 100px;
- }
-}
-
@media (max-width: $screen-xs-max) {
.issues-details-filters {
padding: 0 0 10px;
@@ -478,4 +466,5 @@
.filter-dropdown-loading {
padding: 8px 16px;
+ text-align: center;
}
diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss
index eadb9409fee..38d884bc7eb 100644
--- a/app/assets/stylesheets/framework/flash.scss
+++ b/app/assets/stylesheets/framework/flash.scss
@@ -16,6 +16,22 @@
@extend .alert;
@extend .alert-danger;
margin: 0;
+
+ .flash-text,
+ .flash-action {
+ display: inline-block;
+ }
+
+ a.flash-action {
+ margin-left: 5px;
+ text-decoration: none;
+ font-weight: normal;
+ border-bottom: 1px solid;
+
+ &:hover {
+ border-color: transparent;
+ }
+ }
}
.flash-notice,
@@ -36,6 +52,10 @@
border-radius: 0;
}
}
+
+ &:empty {
+ margin: 0;
+ }
}
@media (max-width: $screen-sm-max) {
diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss
index c0de09f3968..dbdd5a4464b 100644
--- a/app/assets/stylesheets/framework/gfm.scss
+++ b/app/assets/stylesheets/framework/gfm.scss
@@ -2,7 +2,7 @@
* Styles that apply to all GFM related forms.
*/
+.gfm-commit,
.gfm-commit_range {
- font-family: $monospace_font;
- font-size: 90%;
+ @extend .commit-sha;
}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 07566fd8c77..d8645afb7da 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -30,13 +30,27 @@ header {
background-color: $gray-light;
border: none;
border-bottom: 1px solid $border-color;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
@media (max-width: $screen-xs-min) {
padding: 0 16px;
}
&.with-horizontal-nav {
- border-bottom: none;
+ border-bottom: 0;
+
+ .navbar-border {
+ height: 1px;
+ position: absolute;
+ right: 0;
+ left: 0;
+ bottom: -1px;
+ background-color: $border-color;
+ opacity: 0;
+ }
}
.container-fluid {
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index 1b7d4e42258..ef864e8f6a9 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -65,3 +65,7 @@
text-decoration: none;
}
}
+
+.user-avatar-link {
+ text-decoration: none;
+}
diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss
index 20c7bc93c28..9e8acf4e73c 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -25,6 +25,10 @@ body {
.content-wrapper {
padding-bottom: 100px;
+
+ &:not(.page-with-layout-nav) {
+ margin-top: $header-height;
+ }
}
.container {
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index c9a25946ffd..49163653548 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -11,7 +11,6 @@
> li {
padding: 10px 15px;
min-height: 20px;
- border-bottom: 1px solid $list-border-light;
border-bottom: 1px solid $list-border;
&::after {
@@ -152,6 +151,7 @@ ul.content-list {
margin-top: 3px;
margin-bottom: 4px;
+ &.has-tooltip,
&:last-child {
margin-right: 0;
diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss
index 0140dcf19c3..600a1f53b58 100644
--- a/app/assets/stylesheets/framework/mobile.scss
+++ b/app/assets/stylesheets/framework/mobile.scss
@@ -29,10 +29,6 @@
display: none;
}
- .issues-holder .issue-check {
- display: none;
- }
-
.rss-btn {
display: none;
}
diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss
index c5460d280ce..3787ef370b2 100644
--- a/app/assets/stylesheets/framework/nav.scss
+++ b/app/assets/stylesheets/framework/nav.scss
@@ -45,7 +45,8 @@
li {
display: flex;
- a {
+ a,
+ .btn-link {
padding: $gl-btn-padding;
padding-bottom: 11px;
font-size: 14px;
@@ -67,7 +68,29 @@
}
}
- &.active a {
+ .btn-link {
+ padding-top: 16px;
+ padding-left: 15px;
+ padding-right: 15px;
+ border-left: none;
+ border-right: none;
+ border-top: none;
+ border-radius: 0;
+
+ &:hover,
+ &:active,
+ &:focus {
+ background-color: transparent;
+ }
+
+ &:active {
+ outline: 0;
+ box-shadow: none;
+ }
+ }
+
+ &.active a,
+ &.active .btn-link {
border-bottom: 2px solid $link-underline-blue;
color: $black;
font-weight: 600;
@@ -291,6 +314,7 @@
border-bottom: 1px solid $border-color;
transition: padding $sidebar-transition-duration;
text-align: center;
+ margin-top: $header-height;
.container-fluid {
position: relative;
@@ -436,14 +460,14 @@
top: ($header-height + 1) * 3;
&.affix {
- top: 0;
+ top: $header-height;
}
}
}
}
-.activities {
- .nav-block {
+.nav-block {
+ &.activities {
border-bottom: 1px solid $border-color;
.nav-links {
diff --git a/app/assets/stylesheets/framework/notes.scss b/app/assets/stylesheets/framework/notes.scss
new file mode 100644
index 00000000000..d349e3fad9c
--- /dev/null
+++ b/app/assets/stylesheets/framework/notes.scss
@@ -0,0 +1,14 @@
+@mixin notes-media($condition, $breakpoint-width) {
+ @media (#{$condition}-width: ($breakpoint-width)) {
+ @content;
+ }
+
+ // Diff is side by side
+ .notes_content.parallel & {
+ // We hide at double what we normally hide at because
+ // there are two columns of notes
+ @media (#{$condition}-width: (2 * $breakpoint-width)) {
+ @content;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/responsive-tables.scss b/app/assets/stylesheets/framework/responsive-tables.scss
new file mode 100644
index 00000000000..f0a4c66aa1a
--- /dev/null
+++ b/app/assets/stylesheets/framework/responsive-tables.scss
@@ -0,0 +1,91 @@
+@mixin flex-max-width($max) {
+ flex: 0 0 #{$max + '%'};
+ max-width: #{$max + '%'};
+}
+
+.gl-responsive-table-row {
+ margin-top: 10px;
+ border: 1px solid $border-color;
+
+ @media (min-width: $screen-md-min) {
+ padding: 15px 0;
+ margin: 0;
+ display: flex;
+ align-items: center;
+ border: none;
+ border-bottom: 1px solid $white-normal;
+ }
+
+ .table-section {
+ white-space: nowrap;
+
+ $section-widths: 10 15 20 25 30 40;
+ @each $width in $section-widths {
+ &.section-#{$width} {
+ flex: 0 0 #{$width + '%'};
+
+ @media (min-width: $screen-md-min) {
+ max-width: #{$width + '%'};
+ }
+ }
+ }
+
+ &:not(.table-button-footer) {
+ @media (max-width: $screen-sm-max) {
+ display: flex;
+ align-self: stretch;
+ padding: 10px;
+ align-items: center;
+ height: 62px;
+
+ &:not(:first-of-type) {
+ border-top: 1px solid $white-normal;
+ }
+ }
+ }
+ }
+}
+
+.table-row-header {
+ font-size: 13px;
+
+ @media (max-width: $screen-sm-max) {
+ display: none;
+ }
+}
+
+.table-mobile-header {
+ color: $gl-text-color-secondary;
+ @include flex-max-width(40);
+
+ @media (min-width: $screen-md-min) {
+ display: none;
+ }
+}
+
+.table-mobile-content {
+ @media (max-width: $screen-sm-max) {
+ @include flex-max-width(60);
+ text-align: right;
+ }
+}
+
+.flex-truncate-parent {
+ display: flex;
+}
+
+.flex-truncate-child {
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ @media (min-width: $screen-md-min) {
+ flex: 0 0 90%;
+ }
+
+ .avatar {
+ float: none;
+ margin-right: 4px;
+ }
+}
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index 9ab17e67d4c..5ae833cd5f6 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -96,7 +96,6 @@
.select2-search-field input {
padding: 5px $gl-padding / 2;
- font-size: 13px;
height: auto;
font-family: inherit;
font-size: inherit;
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index cf30c256395..135520e0c07 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -33,7 +33,7 @@
padding-right: 0;
@media (min-width: $screen-sm-min) {
- &:not(.wiki-sidebar):not(.build-sidebar) .content-wrapper {
+ &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper {
padding-right: $gutter_collapsed_width;
}
@@ -56,7 +56,7 @@
z-index: 300;
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
- &:not(.wiki-sidebar):not(.build-sidebar) .content-wrapper {
+ &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper {
padding-right: $gutter_collapsed_width;
}
}
@@ -81,7 +81,43 @@
&.affix {
position: fixed;
- top: 0;
+ top: $header-height;
+ }
+
+ &:not(.affix-top) {
+ min-height: 100%;
+ }
+}
+
+@mixin maintain-sidebar-dimensions {
+ display: block;
+ width: $gutter-width;
+ padding: 10px 20px;
+}
+
+.issues-bulk-update.right-sidebar {
+ @include maintain-sidebar-dimensions;
+ transition: right $sidebar-transition-duration;
+ right: -$gutter-width;
+
+ &.right-sidebar-expanded {
+ @include maintain-sidebar-dimensions;
+ right: 0;
+ }
+
+ &.right-sidebar-collapsed {
+ @include maintain-sidebar-dimensions;
+ right: -$gutter-width;
+
+ .block {
+ padding: 16px 0;
+ width: 250px;
+ border-bottom: 1px solid $border-color;
+ }
+ }
+
+ .issuable-sidebar {
+ padding: 0 3px;
}
&:not(.affix-top) {
diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss
index 70db1962228..0d328031dcf 100644
--- a/app/assets/stylesheets/framework/timeline.scss
+++ b/app/assets/stylesheets/framework/timeline.scss
@@ -3,9 +3,9 @@
margin: 0;
padding: 0;
- .note-text {
- p:last-child {
- margin-bottom: 0;
+ &::before {
+ @include notes-media('max', $screen-xs-min) {
+ background: none;
}
}
@@ -29,6 +29,15 @@
.timeline-entry-inner {
position: relative;
+
+ @include notes-media('max', $screen-xs-min) {
+ .timeline-icon {
+ display: none;
+ }
+
+ .timeline-content {
+ margin-left: 0;
+ }
}
&:target,
@@ -46,24 +55,6 @@
}
}
-@media (max-width: $screen-xs-max) {
- .timeline {
- &::before {
- background: none;
- }
- }
-
- .timeline-entry .timeline-entry-inner {
- .timeline-icon {
- display: none;
- }
-
- .timeline-content {
- margin-left: 0;
- }
- }
-}
-
.discussion .timeline-entry {
margin: 0;
border-right: none;
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 96d8a812723..785b09e622f 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -21,6 +21,10 @@
margin-top: 0;
}
+ > :last-child {
+ margin-bottom: 0;
+ }
+
// Single code lines should wrap
code {
font-family: $monospace_font;
@@ -139,6 +143,15 @@
line-height: 1.6em;
overflow-x: auto;
border-radius: 2px;
+
+
+ &.plain-readme {
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ font-size: 14px;
+ }
}
p > code {
@@ -148,7 +161,7 @@
ul,
ol {
padding: 0;
- margin: 0 0 16px !important;
+ margin: 0 0 16px;
}
ul:dir(rtl),
@@ -169,14 +182,14 @@
}
ul.task-list {
- li.task-list-item {
+ > li.task-list-item {
list-style-type: none;
position: relative;
min-height: 22px;
padding-left: 28px;
margin-left: 0 !important;
- input.task-list-item-checkbox {
+ > input.task-list-item-checkbox {
position: absolute;
left: 8px;
top: 5px;
@@ -279,19 +292,6 @@ h6 {
/** CODE **/
pre {
font-family: $monospace_font;
-
- &.plain-readme {
- background: none;
- border: none;
- padding: 0;
- margin: 0;
- font-size: 14px;
- }
-}
-
-.monospace {
- font-family: $monospace_font;
- font-size: 90%;
}
code {
@@ -305,6 +305,24 @@ a > code {
color: $link-color;
}
+.monospace {
+ font-family: $monospace_font;
+}
+
+.commit-sha,
+.ref-name {
+ @extend .monospace;
+ font-size: 95%;
+}
+
+.git-revision-dropdown-toggle {
+ @extend .monospace;
+}
+
+.git-revision-dropdown .dropdown-content ul li a {
+ @extend .ref-name;
+}
+
/**
* Apply Markdown typography
*
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 4cfa5d718e9..4114a050d9a 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -163,7 +163,7 @@ $fixed-layout-width: 1280px;
$limited-layout-width: 990px;
$gl-avatar-size: 40px;
$error-exclamation-point: $red-500;
-$border-radius-default: 2px;
+$border-radius-default: 3px;
$settings-icon-size: 18px;
$provider-btn-not-active-color: $blue-500;
$link-underline-blue: $blue-500;
@@ -187,6 +187,7 @@ $divergence-graph-bar-bg: #ccc;
$divergence-graph-separator-bg: #ccc;
$general-hover-transition-duration: 100ms;
$general-hover-transition-curve: linear;
+$highlight-changes-color: rgb(235, 255, 232);
/*
@@ -247,7 +248,6 @@ $dark-diff-match-bg: rgba(255, 255, 255, 0.3);
$dark-diff-match-color: rgba(255, 255, 255, 0.1);
$file-mode-changed: #777;
$file-mode-changed: #777;
-$diff-image-bg: #ddd;
$diff-image-info-color: grey;
$diff-swipe-border: #999;
$diff-view-modes-color: grey;
@@ -294,7 +294,7 @@ $btn-white-active: #848484;
/*
* Badges
*/
-$badge-bg: #eee;
+$badge-bg: rgba(0, 0, 0, 0.07);
$badge-color: $gl-text-color-secondary;
/*
@@ -570,3 +570,10 @@ $filter-value-selected-color: #d7d7d7;
Animation Functions
*/
$dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1);
+
+/*
+Convdev Index
+*/
+$color-high-score: $green-400;
+$color-average-score: $orange-400;
+$color-low-score: $red-400;
diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss
index 32eb750180f..1c1392f8f67 100644
--- a/app/assets/stylesheets/framework/wells.scss
+++ b/app/assets/stylesheets/framework/wells.scss
@@ -12,10 +12,14 @@
}
&.branch-info {
- .monospace,
+ .commit-sha,
.commit-info {
margin-left: 4px;
}
+
+ .ref-name {
+ font-size: 12px;
+ }
}
}
diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss
index 09951fe3d3e..6e3829d994f 100644
--- a/app/assets/stylesheets/highlight/dark.scss
+++ b/app/assets/stylesheets/highlight/dark.scss
@@ -185,6 +185,11 @@ $dark-il: #de935f;
color: $dark-highlight-color !important;
}
+ // Links to URLs, emails, or dependencies
+ .line a {
+ color: $dark-na;
+ }
+
.hll { background-color: $dark-hll-bg; }
.c { color: $dark-c; } /* Comment */
.err { color: $dark-err; } /* Error */
diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss
index b6a6d298adf..68eb0c7720f 100644
--- a/app/assets/stylesheets/highlight/monokai.scss
+++ b/app/assets/stylesheets/highlight/monokai.scss
@@ -185,6 +185,11 @@ $monokai-gi: #a6e22e;
color: $black !important;
}
+ // Links to URLs, emails, or dependencies
+ .line a {
+ color: $monokai-k;
+ }
+
.hll { background-color: $monokai-hll; }
.c { color: $monokai-c; } /* Comment */
.err { color: $monokai-err-color; background-color: $monokai-err-bg; } /* Error */
diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss
index 4f7a50dcb4f..2cc968c32f2 100644
--- a/app/assets/stylesheets/highlight/solarized_dark.scss
+++ b/app/assets/stylesheets/highlight/solarized_dark.scss
@@ -188,6 +188,11 @@ $solarized-dark-il: #2aa198;
background-color: $solarized-dark-highlight !important;
}
+ // Links to URLs, emails, or dependencies
+ .line a {
+ color: $solarized-dark-kd;
+ }
+
/* Solarized Dark
For use with Jekyll and Pygments
diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss
index 6463fe96c1b..b61b85a2cd1 100644
--- a/app/assets/stylesheets/highlight/solarized_light.scss
+++ b/app/assets/stylesheets/highlight/solarized_light.scss
@@ -196,6 +196,11 @@ $solarized-light-il: #2aa198;
background-color: $solarized-light-highlight !important;
}
+ // Links to URLs, emails, or dependencies
+ .line a {
+ color: $solarized-light-kd;
+ }
+
/* Solarized Light
For use with Jekyll and Pygments
diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss
index ab2018bfbca..1daa10aef24 100644
--- a/app/assets/stylesheets/highlight/white.scss
+++ b/app/assets/stylesheets/highlight/white.scss
@@ -203,6 +203,11 @@ $white-gc-bg: #eaf2f5;
background-color: $white-highlight !important;
}
+ // Links to URLs, emails, or dependencies
+ .line a {
+ color: $white-nb;
+ }
+
.hll { background-color: $white-hll-bg; }
.c { color: $white-c; font-style: italic; }
.err { color: $white-err; background-color: $white-err-bg; }
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index 68d7ab4bf84..740e383dbb5 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -72,7 +72,9 @@
@media (min-width: $screen-sm-min) {
height: 475px; // Needed for PhantomJS
+ // scss-lint:disable DuplicateProperty
height: calc(100vh - 222px);
+ // scss-lint:enable DuplicateProperty
min-height: 475px;
transition: width .2s;
@@ -94,9 +96,51 @@
@media (min-width: $screen-sm-min) {
width: 400px;
}
+
+ &.is-expandable {
+ .board-header {
+ cursor: pointer;
+ }
+ }
+
+ &.is-collapsed {
+ width: 50px;
+
+ .board-header {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ }
+
+ .board-title {
+ position: initial;
+ padding: 0;
+ border-bottom: 0;
+
+ > span {
+ display: block;
+ transform: rotate(90deg) translate(25px, 0);
+ }
+ }
+
+ .board-title-expandable-toggle {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin-left: -10px;
+ }
+
+ .board-list-component,
+ .board-issue-count-holder {
+ display: none;
+ }
+ }
}
.board-inner {
+ position: relative;
height: 100%;
font-size: $issue-boards-font-size;
background: $gray-light;
@@ -173,21 +217,53 @@
}
}
+.slide-down-enter {
+ transform: translateY(-100%);
+}
+
+.slide-down-enter-active {
+ transition: transform $fade-in-duration;
+
+ + .board-list {
+ transform: translateY(-136px);
+ transition: none;
+ }
+}
+
+.slide-down-enter-to {
+ + .board-list {
+ transform: translateY(0);
+ transition: transform $fade-in-duration ease;
+ }
+}
+
+.slide-down-leave {
+ transform: translateY(0);
+}
+
+.slide-down-leave-active {
+ transition: all $fade-in-duration;
+ transform: translateY(-136px);
+
+ + .board-list {
+ transition: transform $fade-in-duration ease;
+ transform: translateY(-136px);
+ }
+}
+
.board-list-component {
height: calc(100% - 49px);
+ overflow: hidden;
}
.board-list {
height: 100%;
+ width: 100%;
margin-bottom: 0;
padding: 5px;
list-style: none;
overflow-y: scroll;
overflow-x: hidden;
-
- &.is-smaller {
- height: calc(100% - 136px);
- }
}
.board-list-loading {
@@ -349,6 +425,7 @@
}
.board-new-issue-form {
+ z-index: 1;
margin: 5px;
}
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 14a62b6cbf0..e35558ad8e8 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -29,129 +29,140 @@
}
}
-.build-page {
- pre.trace {
- background: $builds-trace-bg;
- color: $white-light;
- font-family: $monospace_font;
- white-space: pre-wrap;
- overflow: auto;
- overflow-y: hidden;
- font-size: 12px;
-
- .fa-spinner {
- font-size: 24px;
- margin-left: 20px;
- }
- }
-
- .environment-information {
- background-color: $gray-light;
- border: 1px solid $border-color;
- padding: 12px $gl-padding;
- border-radius: $border-radius-default;
+@keyframes blinking-scroll-button {
+ 0% { opacity: 0.2; }
+ 25% { opacity: 0.5; }
+ 50% { opacity: 0.7; }
+ 100% { opacity: 1; }
+}
- svg {
- position: relative;
- top: 1px;
- margin-right: 5px;
- }
+.build-page {
+ .sticky {
+ position: absolute;
+ left: 0;
+ right: 0;
}
- .truncated-info {
- text-align: center;
- border-bottom: 1px solid;
- background-color: $black;
- height: 45px;
- padding: 15px;
+ .build-trace-container {
+ position: absolute;
+ top: 225px;
+ left: 15px;
+ bottom: 10px;
+ background: $black;
+ color: $gray-darkest;
+ font-family: $monospace_font;
+ font-size: 12px;
- &.affix {
- top: 0;
+ &.sidebar-expanded {
+ right: 305px;
}
- // with sidebar
- &.affix.sidebar-expanded {
- right: 312px;
- left: 22px;
+ &.sidebar-collapsed {
+ right: 16px;
}
- // without sidebar
- &.affix.sidebar-collapsed {
- right: 20px;
- left: 20px;
+ code {
+ background: $black;
+ color: $gray-darkest;
}
- &.affix-top {
- position: absolute;
+ .top-bar {
top: 0;
- margin: 0 auto;
- right: 5px;
- left: 5px;
- }
+ height: 35px;
+ display: flex;
+ justify-content: flex-end;
+ border-bottom: 1px outset $white-light;
- .truncated-info-size {
- margin: 0 5px;
- }
+ .truncated-info {
+ margin: 0 auto;
+ align-self: center;
- .raw-link {
- color: inherit;
- margin-left: 5px;
- text-decoration: underline;
+ .truncated-info-size {
+ margin: 0 5px;
+ }
+
+ .raw-link {
+ color: inherit;
+ margin-left: 5px;
+ text-decoration: underline;
+ }
+ }
}
- }
-}
-.scroll-controls {
- height: 100%;
+ .controllers {
+ display: flex;
+ align-self: center;
+ font-size: 15px;
- .scroll-step {
- width: 31px;
- margin: 0 0 0 auto;
- }
+ svg {
+ height: 15px;
+ display: block;
+ fill: $white-light;
+ }
- .scroll-link,
- .autoscroll-container {
- right: 25px;
- z-index: 1;
- }
+ a,
+ .btn-scroll {
+ margin: 0 8px;
+ color: $white-light;
+ }
- .scroll-link {
- position: fixed;
- display: block;
- margin-bottom: 10px;
+ .btn-scroll.animate {
+ .first-triangle {
+ animation: blinking-scroll-button 1s ease infinite;
+ animation-delay: .3s;
+ }
- &.scroll-top .gitlab-icon-scroll-up-hover,
- &.scroll-top:hover .gitlab-icon-scroll-up,
- &.scroll-bottom .gitlab-icon-scroll-down-hover,
- &.scroll-bottom:hover .gitlab-icon-scroll-down {
- display: none;
- }
+ .second-triangle {
+ animation: blinking-scroll-button 1s ease infinite;
+ animation-delay: .2s;
+ }
- &.scroll-top:hover .gitlab-icon-scroll-up-hover,
- &.scroll-bottom:hover .gitlab-icon-scroll-down-hover {
- display: inline-block;
- }
+ .third-triangle {
+ animation: blinking-scroll-button 1s ease infinite;
+ }
- &.scroll-top {
- top: 10px;
- }
+ &:disabled {
+ opacity: 1;
+ }
+ }
- &.scroll-bottom {
- bottom: -2px;
+ .btn-scroll:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+ }
}
}
- .autoscroll-container {
- position: absolute;
+ .bash {
+ top: 35px;
+ left: 10px;
+ bottom: 0;
+ overflow-y: hidden;
+ padding-bottom: 20px;
+ padding-right: 20px;
}
- &.sidebar-expanded {
+ .environment-information {
+ background-color: $gray-light;
+ border: 1px solid $border-color;
+ padding: 12px $gl-padding;
+ border-radius: $border-radius-default;
- .scroll-link,
- .autoscroll-container {
- right: ($gutter_width + ($gl-padding * 2));
+ svg {
+ position: relative;
+ top: 1px;
+ margin-right: 5px;
}
}
+
+ .build-loader-animation {
+ position: relative;
+ width: 6px;
+ height: 6px;
+ margin: auto auto 12px 2px;
+ border-radius: 50%;
+ animation: blinking-dots 1s linear infinite;
+ }
}
.status-message {
@@ -223,32 +234,6 @@
}
}
-.build-trace {
- background: $black;
- color: $gray-darkest;
- white-space: pre;
- overflow-x: auto;
- font-size: 12px;
- position: relative;
-
- .fa-spinner {
- font-size: 24px;
- }
-
- .bash {
- display: block;
- }
-
- .build-loader-animation {
- position: relative;
- width: 6px;
- height: 6px;
- margin: auto auto 12px 2px;
- border-radius: 50%;
- animation: blinking-dots 1s linear infinite;
- }
-}
-
.right-sidebar.build-sidebar {
padding: $gl-padding 0;
@@ -390,6 +375,10 @@
.container-fluid.container-limited {
max-width: 100%;
}
+
+ .content-wrapper {
+ padding-bottom: 6px;
+ }
}
.build-detail-row {
diff --git a/app/assets/stylesheets/pages/ci_projects.scss b/app/assets/stylesheets/pages/ci_projects.scss
index 90643832390..7b4eb689f1b 100644
--- a/app/assets/stylesheets/pages/ci_projects.scss
+++ b/app/assets/stylesheets/pages/ci_projects.scss
@@ -36,7 +36,6 @@
pre.commit-message {
background: none;
padding: 0;
- margin: 0;
border: none;
margin: 20px 0;
border-radius: 0;
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 9e3142c8aa3..9db0f2075cb 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -163,7 +163,6 @@
.avatar-cell {
width: 46px;
- padding-left: 10px;
img {
margin-right: 0;
@@ -175,7 +174,6 @@
justify-content: space-between;
align-items: flex-start;
flex-grow: 1;
- padding-left: 10px;
.merge-request-branches & {
flex-direction: column;
@@ -208,11 +206,11 @@
margin-left: $gl-padding;
}
}
-}
-.commit-short-id {
- font-family: $monospace_font;
- font-weight: 600;
+ .commit-sha {
+ font-size: 14px;
+ font-weight: 600;
+ }
}
.commit,
@@ -230,7 +228,7 @@
margin: 10px 0;
background: $gray-light;
display: none;
- white-space: pre-line;
+ white-space: pre-wrap;
word-break: normal;
pre {
@@ -273,7 +271,7 @@
}
}
- .commit-id {
+ .commit-sha {
color: $gl-link-color;
}
diff --git a/app/assets/stylesheets/pages/convdev_index.scss b/app/assets/stylesheets/pages/convdev_index.scss
new file mode 100644
index 00000000000..0413114c279
--- /dev/null
+++ b/app/assets/stylesheets/pages/convdev_index.scss
@@ -0,0 +1,255 @@
+$space-between-cards: 8px;
+
+.convdev-empty svg {
+ margin: 64px auto 32px;
+ max-width: 420px;
+}
+
+.convdev-header {
+ margin-top: $gl-padding;
+ margin-bottom: $gl-padding;
+ padding: 0 4px;
+ display: flex;
+ align-items: center;
+
+ .convdev-header-title {
+ font-size: 48px;
+ line-height: 1;
+ margin: 0;
+ }
+
+ .convdev-header-subtitle {
+ font-size: 22px;
+ line-height: 1;
+ color: $gl-text-color-secondary;
+ margin-left: 8px;
+ font-weight: 500;
+
+ a {
+ font-size: 18px;
+ color: $gl-text-color-secondary;
+
+ &:hover {
+ color: $blue-500;
+ }
+ }
+ }
+}
+
+.convdev-cards {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+.convdev-card-wrapper {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ text-align: center;
+ width: 50%;
+ border-color: $border-color;
+ margin: 0 0 32px;
+ padding: $space-between-cards / 2;
+ position: relative;
+
+ @media (min-width: $screen-xs-min) {
+ width: percentage(1 / 4);
+ }
+
+ @media (min-width: $screen-sm-min) {
+ width: percentage(1 / 5);
+ }
+
+ @media (min-width: $screen-md-min) {
+ width: percentage(1 / 6);
+ }
+
+ @media (min-width: $screen-lg-min) {
+ width: percentage(1 / 10);
+ }
+}
+
+.convdev-card {
+ border: solid 1px $border-color;
+ border-radius: 3px;
+ border-top-width: 3px;
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+}
+
+.convdev-card-low {
+ border-top-color: $color-low-score;
+
+ .card-score-big {
+ background-color: $red-25;
+ }
+}
+
+.convdev-card-average {
+ border-top-color: $color-average-score;
+
+ .card-score-big {
+ background-color: $orange-25;
+ }
+}
+
+.convdev-card-high {
+ border-top-color: $color-high-score;
+
+ .card-score-big {
+ background-color: $green-25;
+ }
+}
+
+.convdev-card-title {
+ margin: $gl-padding auto auto;
+ max-width: 100px;
+
+ h3 {
+ font-size: 14px;
+ margin: 0 0 2px;
+ }
+
+ .text-light {
+ font-size: 13px;
+ line-height: 1.25;
+ color: $gl-text-color-secondary;
+ }
+}
+
+.card-scores {
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+ margin: $gl-padding $gl-btn-padding;
+ line-height: 1;
+}
+
+.card-score {
+ color: $gl-text-color-secondary;
+
+ .card-score-name {
+ font-size: 13px;
+ margin-top: 4px;
+ }
+}
+
+.card-score-value {
+ font-size: 16px;
+ color: $gl-text-color;
+ font-weight: 500;
+}
+
+.card-score-big {
+ border-top: 2px solid $border-color;
+ border-bottom: 1px solid $border-color;
+ font-size: 22px;
+ padding: 10px 0;
+ font-weight: 500;
+}
+
+.card-buttons {
+ display: flex;
+
+ > * {
+ font-size: 16px;
+ color: $gl-text-color-secondary;
+ padding: 10px;
+ flex-grow: 1;
+
+ &:hover {
+ background-color: $border-color;
+ color: $gl-text-color;
+ }
+
+ + * {
+ border-left: solid 1px $border-color;
+ }
+ }
+}
+
+.convdev-steps {
+ margin-top: $gl-padding;
+ height: 1px;
+ min-width: 100%;
+ justify-content: space-around;
+ position: relative;
+ background: $border-color;
+}
+
+.convdev-step {
+ $step-positions: 5% 10% 30% 42% 48% 55% 60% 70% 75% 90%;
+ @each $pos in $step-positions {
+ $i: index($step-positions, $pos);
+
+ &:nth-child(#{$i}) {
+ left: $pos;
+ }
+ }
+
+ position: absolute;
+ transform-origin: 75% 50%;
+ padding: 8px;
+ height: 50px;
+ width: 50px;
+ border-radius: 3px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ border: solid 1px $border-color;
+ background: $white-light;
+ transform: translate(-50%, -50%);
+ color: $gl-text-color-secondary;
+ fill: $gl-text-color-secondary;
+ box-shadow: 0 2px 4px $dropdown-shadow-color;
+
+ &:hover {
+ padding: 8px 10px;
+ fill: currentColor;
+ z-index: 100;
+ height: auto;
+ width: auto;
+
+ .convdev-step-title {
+ max-height: 2em;
+ opacity: 1;
+ transition: opacity 0.2s;
+ }
+
+ svg {
+ transform: scale(1.5);
+ margin: $gl-btn-padding;
+ }
+ }
+
+ svg {
+ transition: transform 0.1s;
+ width: 30px;
+ height: 30px;
+ min-height: 30px;
+ min-width: 30px;
+ }
+}
+
+.convdev-step-title {
+ max-height: 0;
+ opacity: 0;
+ text-transform: uppercase;
+ margin: $gl-vert-padding 0 0;
+ text-align: center;
+ font-size: 12px;
+}
+
+.convdev-high-score {
+ color: $color-high-score;
+}
+
+.convdev-average-score {
+ color: $color-average-score;
+}
+
+.convdev-low-score {
+ color: $color-low-score;
+}
diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss
index d29944207c5..7bec4bd5f56 100644
--- a/app/assets/stylesheets/pages/cycle_analytics.scss
+++ b/app/assets/stylesheets/pages/cycle_analytics.scss
@@ -387,7 +387,7 @@
padding: 0 3px 0 0;
}
- .branch-name {
+ .ref-name {
color: $black;
display: inline-block;
max-width: 180px;
@@ -398,7 +398,7 @@
vertical-align: top;
}
- .short-sha {
+ .commit-sha {
color: $gl-link-color;
line-height: 1.3;
vertical-align: top;
diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss
index f3de05aa5f6..3d9eff35583 100644
--- a/app/assets/stylesheets/pages/detail_page.scss
+++ b/app/assets/stylesheets/pages/detail_page.scss
@@ -4,11 +4,7 @@
color: $gl-text-color;
line-height: 34px;
- .author {
- color: $gl-text-color;
- }
-
- .identifier {
+ a {
color: $gl-text-color;
}
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index 77f2638683a..b58922626fa 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -1,38 +1,6 @@
// Common
.diff-file {
- border: 1px solid $border-color;
margin-bottom: $gl-padding;
- border-radius: 3px;
-
- .commit-short-id {
- font-family: $regular_font;
- font-weight: 400;
- }
-
- .diff-header {
- position: relative;
- background: $gray-light;
- border-bottom: 1px solid $border-color;
- padding: 10px 16px;
- color: $gl-text-color;
- z-index: 10;
- border-radius: 3px 3px 0 0;
-
- .diff-title {
- font-family: $monospace_font;
- word-break: break-all;
- display: block;
-
- .file-mode {
- color: $file-mode-changed;
- }
- }
-
- .commit-short-id {
- font-family: $monospace_font;
- font-size: smaller;
- }
- }
.file-title,
.file-title-flex-parent {
@@ -126,7 +94,6 @@
.old_line,
.new_line {
margin: 0;
- padding: 0;
border: none;
padding: 0 5px;
border-right: 1px solid;
@@ -183,14 +150,10 @@
}
}
}
-
- .text-file.diff-wrap-lines table .line_holder td span {
- white-space: pre-wrap;
- }
}
.image {
- background: $diff-image-bg;
+ background: $file-image-bg;
text-align: center;
padding: 30px;
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index 026d35295d7..b24803678ea 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -5,45 +5,13 @@
}
}
-.environments-list-loading {
- width: 100%;
- font-size: 34px;
-}
-
.environments-folder-name {
font-weight: normal;
padding-top: 20px;
}
.environments-container {
- .table-holder {
- width: 100%;
-
- @media (max-width: $screen-sm-max) {
- overflow: auto;
- }
- }
-
- .table.ci-table {
- .environments-actions {
- min-width: 300px;
- }
-
- .environments-commit,
- .environments-actions {
- width: 20%;
- }
-
- .environments-date {
- width: 10%;
- }
-
- .environments-name,
- .environments-deploy,
- .environments-build {
- width: 15%;
- }
-
+ .ci-table {
.deployment-column {
> span {
word-break: break-all;
@@ -69,12 +37,12 @@
}
}
- .commit-title {
- margin: 0;
+ .btn .text-center {
+ display: inline;
}
- .avatar-image-container {
- text-decoration: none;
+ .commit-title {
+ margin: 0;
}
.icon-play {
@@ -95,7 +63,7 @@
}
.build-link,
- .branch-name {
+ .ref-name {
color: $gl-text-color;
}
@@ -140,7 +108,7 @@
}
.branch-commit {
- .commit-id {
+ .commit-sha {
margin-right: 0;
}
}
@@ -155,6 +123,59 @@
}
}
+.gl-responsive-table-row {
+ .environments-actions {
+ @media (min-width: $screen-md-min) {
+ text-align: right;
+ }
+
+ @media (max-width: $screen-sm-max) {
+ background-color: $gray-normal;
+ align-self: stretch;
+ border-top: 1px solid $border-color;
+
+ .environment-action-buttons {
+ padding: 10px 5px;
+ display: flex;
+
+ .btn {
+ border-radius: 3px;
+ }
+
+ > .btn-group,
+ > .external-url,
+ > .btn {
+ flex: 1;
+ flex-basis: 28px;
+ margin: 0 5px;
+ }
+
+ .dropdown-new {
+ width: 100%;
+ }
+
+ .dropdown-menu {
+ min-width: initial;
+ }
+ }
+ }
+ }
+
+ .branch-commit {
+ max-width: 100%;
+ }
+}
+
+.folder-row {
+ padding: 15px 0;
+ border-bottom: 1px solid $white-normal;
+
+ @media (max-width: $screen-sm-max) {
+ border-top: 1px solid $white-normal;
+ margin-top: 10px;
+ }
+}
+
.prometheus-graph {
text {
fill: $gl-text-color;
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index f8944e5ce03..a321941e0c9 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -198,8 +198,17 @@
right: 0;
transition: width .3s;
background: $gray-light;
- padding: 10px 20px;
+ padding: 0 20px;
z-index: 200;
+ overflow: hidden;
+
+ .issuable-sidebar {
+ width: calc(100% + 100px);
+ height: 100%;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ }
&.right-sidebar-expanded {
width: $gutter_width;
@@ -213,6 +222,10 @@
}
}
+ .issuable-sidebar-header {
+ padding-top: 10px;
+ }
+
.assign-yourself .btn-link {
padding-left: 0;
}
@@ -266,11 +279,10 @@
}
width: $gutter_collapsed_width;
- padding-top: 0;
+ padding: 0;
.block {
width: $gutter_collapsed_width - 2px;
- margin-left: -19px;
padding: 15px 0 0;
border-bottom: none;
overflow: hidden;
@@ -419,7 +431,7 @@
}
.detail-page-description {
- padding: 16px 0 0;
+ padding: 16px 0;
small {
color: $gray-darkest;
@@ -429,7 +441,7 @@
.edited-text {
color: $gray-darkest;
display: block;
- margin: 0 0 16px;
+ margin: 16px 0 0;
.author_link {
color: $gray-darkest;
@@ -717,3 +729,34 @@
}
}
}
+
+.confidential-issue-warning {
+ background-color: $gl-gray;
+ border-radius: 3px;
+ padding: $gl-btn-padding $gl-padding;
+ margin-top: $gl-padding-top;
+ font-size: 14px;
+ color: $white-light;
+
+ .fa {
+ margin-right: 8px;
+ }
+
+ a {
+ color: $white-light;
+ text-decoration: underline;
+ }
+
+ &.affix {
+ position: static;
+ width: initial;
+
+ @media (min-width: $screen-sm-min) {
+ position: sticky;
+ position: -webkit-sticky;
+ top: 60px;
+ z-index: 200;
+ @include transition(all);
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index ad3b6e0344b..702e7662527 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -51,6 +51,7 @@ ul.related-merge-requests > li {
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
+ align-items: center;
.merge-request-id {
flex-shrink: 0;
@@ -59,6 +60,14 @@ ul.related-merge-requests > li {
.merge-request-info {
margin-left: 5px;
}
+
+ .row_title {
+ vertical-align: bottom;
+ }
+
+ gl-emoji {
+ font-size: 1em;
+ }
}
.merge-requests-title,
@@ -114,7 +123,6 @@ ul.related-merge-requests > li {
.related-merge-requests {
.ci-status-link {
display: block;
- margin-top: 3px;
margin-right: 5px;
}
@@ -196,7 +204,6 @@ ul.related-merge-requests > li {
.dropdown-toggle {
.fa-caret-down {
pointer-events: none;
- margin-left: 0;
color: inherit;
margin-left: 0;
}
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss
index 8dbac76e30a..971d54e7472 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/pages/members.scss
@@ -184,4 +184,4 @@
}
}
}
-} \ 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 f747e7b1660..2dc7f73a295 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -132,12 +132,6 @@
line-height: 16px;
}
- @media (min-width: $screen-sm-min) {
- .stage-cell {
- padding: 0 4px;
- }
- }
-
@media (max-width: $screen-xs-max) {
order: 1;
margin-top: $gl-padding-top;
@@ -167,6 +161,34 @@
text-transform: capitalize;
}
+ .label-branch {
+ @extend .ref-name;
+
+ color: $gl-text-color;
+ font-weight: bold;
+ overflow: hidden;
+ margin: 0 3px;
+ word-break: break-all;
+
+ &.label-truncated {
+ position: relative;
+ display: inline-block;
+ width: 250px;
+ margin-bottom: -3px;
+ white-space: nowrap;
+ text-overflow: clip;
+ line-height: 14px;
+
+ &::after {
+ position: absolute;
+ content: '...';
+ right: 0;
+ font-family: $regular_font;
+ background-color: $gray-light;
+ }
+ }
+ }
+
.js-deployment-link {
display: inline-block;
}
@@ -349,6 +371,22 @@
margin-top: 10px;
margin-left: 12px;
}
+
+ &.empty-state {
+ .artwork {
+ margin-bottom: $gl-padding;
+ }
+
+ .text {
+ span {
+ font-weight: bold;
+ }
+
+ p {
+ margin-top: $gl-padding;
+ }
+ }
+ }
}
.mr-widget-footer {
@@ -390,34 +428,6 @@
}
}
-.label-branch {
- color: $gl-text-color;
- font-family: $monospace_font;
- font-weight: bold;
- overflow: hidden;
- font-size: 90%;
- margin: 0 3px;
- word-break: break-all;
-
- &.label-truncated {
- position: relative;
- display: inline-block;
- width: 250px;
- margin-bottom: -3px;
- white-space: nowrap;
- text-overflow: clip;
- line-height: 14px;
-
- &::after {
- position: absolute;
- content: '...';
- right: 0;
- font-family: $regular_font;
- background-color: $gray-light;
- }
- }
-}
-
.commits-empty {
text-align: center;
@@ -510,17 +520,13 @@
position: absolute;
border-top: 2px solid $border-color;
height: 1px;
- top: 8px;
+ top: 9px;
width: 8px;
left: 0;
}
&:last-child {
margin-bottom: 0;
-
- &::before {
- top: 14px;
- }
}
}
@@ -529,7 +535,7 @@
width: 2px;
background: $border-color;
position: absolute;
- top: -5px;
+ top: -9px;
}
}
@@ -724,14 +730,18 @@
}
.merge-request-tabs-holder {
+ top: $header-height;
z-index: 100;
background-color: $white-light;
border-bottom: 1px solid $border-color;
+ @media(min-width: $screen-sm-min) {
+ position: sticky;
+ position: -webkit-sticky;
+ }
+
&.affix {
- top: 0;
left: 0;
- z-index: 10;
transition: right .15s;
@media (max-width: $screen-xs-max) {
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 62f654ed343..aa307414737 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -28,7 +28,7 @@
.note-edit-form {
.note-form-actions {
position: relative;
- margin: $gl-padding 0;
+ margin: $gl-padding 0 0;
}
.note-preview-holder {
@@ -103,31 +103,19 @@
}
}
-.confidential-issue-warning {
- background-color: $gray-normal;
- border-radius: 3px;
- padding: 3px 12px;
- margin: auto;
- margin-top: 0;
- text-align: center;
- font-size: 12px;
-
- @media (max-width: $screen-sm-max) {
- // On smaller devices the warning becomes the fourth item in the list,
- // rather than centering, and grows to span the full width of the
- // comment area.
- order: 4;
- -webkit-order: 4;
- margin: 6px auto;
- width: 100%;
- }
-}
-
.discussion-form {
- padding: $gl-padding-top $gl-padding;
+ padding: $gl-padding-top $gl-padding $gl-padding;
background-color: $white-light;
}
+.discussion-notes .disabled-comment {
+ padding: 6px 0;
+}
+
+.notes-form > li {
+ border: 0;
+}
+
.note-edit-form {
display: none;
font-size: 14px;
@@ -164,10 +152,6 @@
.discussion-body,
.diff-file {
- .notes .note {
- padding: 10px 15px;
- }
-
.discussion-reply-holder {
background-color: $white-light;
padding: 10px 16px;
@@ -277,6 +261,7 @@
.toolbar-text {
font-size: 14px;
line-height: 16px;
+ margin-top: 2px;
@media (min-width: $screen-md-min) {
float: left;
@@ -402,3 +387,45 @@
}
}
}
+
+.uploading-container {
+ float: right;
+
+ @media (max-width: $screen-xs-max) {
+ float: left;
+ margin-top: 5px;
+ }
+}
+
+.uploading-error-icon,
+.uploading-error-message {
+ color: $gl-text-red;
+}
+
+.uploading-error-message {
+ @media (max-width: $screen-xs-max) {
+ &::after {
+ content: "\a";
+ white-space: pre;
+ }
+ }
+}
+
+.uploading-progress {
+ margin-right: 5px;
+}
+
+.attach-new-file,
+.button-attach-file,
+.retry-uploading-link {
+ color: $gl-link-color;
+ padding: 0;
+ background: none;
+ border: 0;
+ font-size: 14px;
+ line-height: 16px;
+}
+
+.markdown-selector {
+ color: $gl-link-color;
+}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 99bcf612e8f..e622e5c3f4b 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -14,24 +14,11 @@ ul.notes {
margin: 0;
padding: 0;
- .timeline-icon {
- float: left;
-
- svg {
- width: 16px;
- height: 16px;
- fill: $gray-darkest;
- position: absolute;
- left: 0;
- top: 16px;
- }
- }
-
.timeline-content {
margin-left: 55px;
&.timeline-content-form {
- @media (max-width: $screen-sm-max) {
+ @include notes-media('max', $screen-sm-max) {
margin-left: 0;
}
}
@@ -51,26 +38,30 @@ ul.notes {
}
.discussion {
- overflow: hidden;
display: block;
position: relative;
+
+ .diff-content {
+ overflow: visible;
+ }
}
- .note {
- padding: $gl-padding $gl-btn-padding 0;
+ > li {
+ padding: $gl-padding $gl-btn-padding;
display: block;
position: relative;
border-bottom: 1px solid $white-normal;
+ &:last-child {
+ // Override `.timeline > li:last-child { border-bottom: none; }`
+ 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%);
}
@@ -85,10 +76,6 @@ ul.notes {
&.timeline-entry {
padding: $gl-padding 10px;
}
-
- .system-note {
- padding: 0;
- }
}
&.is-editing {
@@ -130,13 +117,13 @@ ul.notes {
.note-awards {
.js-awards-block {
- margin-bottom: 16px;
+ margin-top: 16px;
}
}
.note-header {
- @media (max-width: $screen-xs-min) {
+ @include notes-media('max', $screen-xs-min) {
.inline {
display: block;
}
@@ -165,10 +152,10 @@ ul.notes {
.system-note {
font-size: 14px;
- padding: 0;
+ padding-left: 0;
clear: both;
- @media (min-width: $screen-sm-min) {
+ @include notes-media('min', $screen-sm-min) {
margin-left: 65px;
}
@@ -202,11 +189,22 @@ ul.notes {
}
}
- .timeline-content {
- padding: 14px 10px;
+ .timeline-icon {
+ float: left;
- @media (min-width: $screen-sm-min) {
- margin-left: 20px;
+ svg {
+ width: 16px;
+ height: 16px;
+ fill: $gray-darkest;
+ position: absolute;
+ left: 0;
+ top: 2px;
+ }
+ }
+
+ .timeline-content {
+ @include notes-media('min', $screen-sm-min) {
+ margin-left: 30px;
}
}
@@ -243,11 +241,6 @@ ul.notes {
ul {
margin: 3px 0 3px 16px !important;
-
- .gfm-commit {
- font-family: $monospace_font;
- font-size: 12px;
- }
}
p:first-child {
@@ -289,10 +282,6 @@ ul.notes {
}
}
- .diff-header > span {
- margin-right: 10px;
- }
-
.line_content {
white-space: pre-wrap;
}
@@ -384,7 +373,7 @@ ul.notes {
display: flex;
justify-content: space-between;
- @media (max-width: $screen-xs-max) {
+ @include notes-media('max', $screen-xs-max) {
flex-flow: row wrap;
}
}
@@ -394,10 +383,20 @@ ul.notes {
padding-bottom: 8px;
}
+.system-note .note-header-info {
+ padding-bottom: 0;
+}
+
+.note-header-author-name {
+ @include notes-media('max', $screen-xs-max) {
+ display: none;
+ }
+}
+
.note-headline-light {
display: inline;
- @media (max-width: $screen-xs-min) {
+ @include notes-media('max', $screen-xs-min) {
display: block;
}
}
@@ -439,7 +438,7 @@ ul.notes {
margin-left: 10px;
color: $gray-darkest;
- @media (max-width: $screen-xs-max) {
+ @include notes-media('max', $screen-xs-max) {
float: none;
margin-left: 0;
}
@@ -447,10 +446,57 @@ ul.notes {
.note-action-button {
margin-left: 8px;
}
+
+ .more-actions-toggle {
+ margin-left: 2px;
+ }
+}
+
+.more-actions {
+ display: inline;
+
+ .tooltip {
+ white-space: nowrap;
+ }
+}
+
+.more-actions-toggle {
+ padding: 0;
+ outline: none;
+
+ &:hover .icon,
+ &:focus .icon {
+ color: $blue-600;
+ }
+
+ .icon {
+ padding: 0 6px;
+ }
+}
+
+.more-actions-dropdown {
+ width: 180px;
+ min-width: 180px;
+ margin-top: $gl-btn-padding;
+
+ li > a,
+ li > .btn {
+ color: $gl-text-color;
+ padding: $gl-btn-padding;
+ width: 100%;
+ text-align: left;
+
+ &:hover,
+ &:focus {
+ color: $gl-text-color;
+ background-color: $blue-25;
+ border-radius: $border-radius-default;
+ }
+ }
}
.discussion-actions {
- @media (max-width: $screen-md-max) {
+ @include notes-media('max', $screen-md-max) {
float: none;
margin-left: 0;
@@ -464,7 +510,7 @@ ul.notes {
display: inline;
line-height: 20px;
- @media (min-width: $screen-sm-min) {
+ @include notes-media('min', $screen-sm-min) {
margin-left: 10px;
line-height: 24px;
}
@@ -554,13 +600,13 @@ ul.notes {
position: relative;
top: -2px;
display: inline-block;
- padding-left: 4px;
- padding-right: 4px;
+ padding-left: 7px;
+ padding-right: 7px;
color: $notes-role-color;
font-size: 12px;
line-height: 20px;
border: 1px solid $border-color;
- border-radius: $border-radius-base;
+ border-radius: $label-border-radius;
}
@@ -596,6 +642,22 @@ ul.notes {
}
}
+.discussion-body,
+.diff-file {
+ .notes .note {
+ padding-left: $gl-padding;
+ padding-right: $gl-padding;
+
+ &.system-note {
+ padding-left: 0;
+
+ @media (min-width: $screen-sm-min) {
+ margin-left: 70px;
+ }
+ }
+ }
+}
+
.diff-file {
.is-over {
.add-diff-note {
@@ -605,17 +667,11 @@ ul.notes {
}
.disabled-comment {
- margin-left: -$gl-padding-top;
- margin-right: -$gl-padding-top;
background-color: $gray-light;
border-radius: $border-radius-base;
border: 1px solid $border-gray-normal;
color: $note-disabled-comment-color;
- line-height: 200px;
-
- .disabled-comment-text {
- line-height: normal;
- }
+ padding: 90px 0;
a {
color: $gl-link-color;
@@ -623,7 +679,7 @@ ul.notes {
}
.line-resolve-all-container {
- @media (min-width: $screen-sm-min) {
+ @include notes-media('min', $screen-sm-min) {
margin-right: 0;
padding-left: $gl-padding;
}
@@ -665,7 +721,7 @@ ul.notes {
.line-resolve-all {
vertical-align: middle;
display: inline-block;
- padding: 6px 10px;
+ padding: 5px 10px 6px;
background-color: $gray-light;
border: 1px solid $border-color;
border-radius: $border-radius-default;
@@ -678,6 +734,10 @@ ul.notes {
.line-resolve-btn {
margin-right: 5px;
+
+ svg {
+ vertical-align: middle;
+ }
}
}
@@ -714,6 +774,10 @@ ul.notes {
}
}
+.line-resolve-text {
+ vertical-align: middle;
+}
+
.discussion-next-btn {
svg {
margin: 0;
@@ -730,11 +794,6 @@ ul.notes {
// Merge request notes in diffs
.diff-file {
- // Diff is side by side
- .notes_content.parallel .note-header .note-headline-light {
- display: block;
- position: relative;
- }
// Diff is inline
.notes_content .note-header .note-headline-light {
display: inline-block;
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 4304e736b58..58b458cd837 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -1,10 +1,4 @@
.pipelines {
- .realtime-loading {
- font-size: 40px;
- text-align: center;
- margin: 0 auto;
- }
-
.stage {
max-width: 90px;
width: 90px;
@@ -14,10 +8,6 @@
white-space: nowrap;
}
- .empty-state {
- margin: 5% auto 0;
- }
-
.table-holder {
width: 100%;
@@ -98,6 +88,10 @@
}
}
+ .btn .text-center {
+ display: inline;
+ }
+
.tooltip {
white-space: nowrap;
}
@@ -168,9 +162,13 @@
float: none;
}
+ .api {
+ @extend .monospace;
+ }
+
.branch-commit {
- .branch-name {
+ .ref-name {
font-weight: bold;
max-width: 120px;
overflow: hidden;
@@ -192,12 +190,11 @@
color: $gl-text-color;
}
- .commit-id {
+ .commit-sha {
color: $gl-link-color;
}
.commit-title {
- margin-top: 4px;
max-width: 225px;
overflow: hidden;
white-space: nowrap;
@@ -230,7 +227,7 @@
.duration,
.finished-at {
color: $gl-text-color-secondary;
- margin: 4px 0;
+ margin: 0;
white-space: nowrap;
.fa {
@@ -257,7 +254,7 @@
.stage-cell {
font-size: 0;
- padding: 10px 4px;
+ padding: 0 4px;
> .stage-container > div > button > span > svg,
> .stage-container > button > svg {
@@ -987,3 +984,11 @@
width: 12px;
}
}
+
+.pipeline-header-container {
+ min-height: 55px;
+
+ .text-center {
+ padding-top: 12px;
+ }
+}
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index fe084eb9397..c207159f606 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -287,6 +287,7 @@ table.u2f-registrations {
.user-callout {
margin: 0 auto;
+ max-width: $screen-lg-min;
.bordered-box {
border: 1px solid $blue-300;
@@ -295,14 +296,15 @@ table.u2f-registrations {
position: relative;
display: flex;
justify-content: center;
+ align-items: center;
}
.landing {
- margin-top: $gl-padding;
- margin-bottom: $gl-padding;
+ padding: 32px;
.close {
position: absolute;
+ top: 20px;
right: 20px;
opacity: 1;
@@ -330,11 +332,20 @@ table.u2f-registrations {
height: 110px;
vertical-align: top;
}
+
+ &.convdev {
+ margin: 0 0 0 30px;
+
+ svg {
+ height: 127px;
+ }
+ }
}
.user-callout-copy {
display: inline-block;
vertical-align: top;
+ max-width: 570px;
}
}
@@ -348,12 +359,20 @@ table.u2f-registrations {
.landing {
.svg-container,
.user-callout-copy {
- margin: 0;
+ margin: 0 auto;
display: block;
svg {
height: 75px;
}
+
+ &.convdev {
+ margin: $gl-padding auto 0;
+
+ svg {
+ height: 120px;
+ }
+ }
}
}
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index c119f0c9b22..a2f781a6a6e 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -29,6 +29,20 @@
& > .form-group {
padding-left: 0;
}
+
+ select option[disabled] {
+ display: none;
+ }
+ }
+
+ select {
+ background: transparent;
+ transition: background 2s ease-out;
+
+ &.highlight-changes {
+ background: $highlight-changes-color;
+ transition: none;
+ }
}
.help-block {
@@ -247,7 +261,6 @@
font-size: 13px;
font-weight: 600;
line-height: 13px;
- padding: $gl-vert-padding $gl-padding;
letter-spacing: .4px;
padding: 6px 14px;
text-align: center;
@@ -384,10 +397,6 @@ a.deploy-project-label {
}
}
-.last-push-widget {
- margin-top: -1px;
-}
-
.fork-namespaces {
.row {
-webkit-flex-wrap: wrap;
@@ -639,59 +648,6 @@ pre.light-well {
}
}
-.project-last-commit {
- background-color: $gray-light;
- border: 1px solid $border-color;
- border-radius: $border-radius-base;
- padding: 12px;
-
- @media (min-width: $screen-sm-min) {
- margin-top: $gl-padding;
- }
-
- .ci-status {
- margin-right: $gl-padding;
- }
-
- .commit-row-message {
- color: $gl-text-color;
- }
-
- .commit_short_id {
- margin-right: 5px;
- color: $gl-link-color;
- font-weight: 600;
- }
-
- .commit-author-link {
- .commit-author-name {
- font-weight: 600;
- }
- }
-}
-
-.project-show-readme {
- .row-content-block {
- background-color: inherit;
- border: none;
- }
-
- .readme-holder {
- padding: $gl-padding 0;
- border-top: 0;
-
- .edit-project-readme {
- z-index: 2;
- position: relative;
- }
-
- .wiki h1 {
- border-bottom: none;
- padding: 0;
- }
- }
-}
-
.git-clone-holder {
width: 380px;
@@ -733,14 +689,16 @@ pre.light-well {
}
}
-.new_protected_branch {
+.new_protected_branch,
+.new-protected-tag {
label {
margin-top: 6px;
font-weight: normal;
}
}
-.create-new-protected-branch-button {
+.create-new-protected-branch-button,
+.create-new-protected-tag-button {
@include dropdown-link;
width: 100%;
@@ -825,7 +783,8 @@ pre.light-well {
}
.compare-form-group {
- .dropdown-menu {
+ .dropdown-menu,
+ .inline-input-group {
width: 100%;
@media (min-width: $screen-sm-min) {
@@ -844,14 +803,6 @@ pre.light-well {
width: auto;
}
}
-
- .inline-input-group {
- width: 100%;
-
- @media (min-width: $screen-sm-min) {
- width: 250px;
- }
- }
}
.clearable-input {
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 3889deee21a..33b3c083fd2 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -1,3 +1,90 @@
+@keyframes expandMaxHeight {
+ 0% {
+ max-height: 0;
+ }
+
+ 99% {
+ max-height: 100vh;
+ }
+
+ 100% {
+ max-height: none;
+ }
+}
+
+@keyframes collapseMaxHeight {
+ 0% {
+ max-height: 100vh;
+ }
+
+ 100% {
+ max-height: 0;
+ }
+}
+
+.settings {
+ overflow: hidden;
+ border-bottom: 1px solid $gray-darker;
+
+ &:first-of-type {
+ margin-top: 10px;
+ }
+}
+
+.settings-header {
+ position: relative;
+ padding: 20px 110px 10px 0;
+
+ h4 {
+ margin-top: 0;
+ }
+
+ button {
+ position: absolute;
+ top: 20px;
+ right: 6px;
+ min-width: 80px;
+ }
+}
+
+.settings-content {
+ max-height: 1px;
+ overflow-y: scroll;
+ margin-right: -20px;
+ padding-right: 130px;
+ animation: collapseMaxHeight 300ms ease-out;
+
+ &.expanded {
+ max-height: none;
+ overflow-y: visible;
+ animation: expandMaxHeight 300ms ease-in;
+ }
+
+ &.no-animate {
+ animation: none;
+ }
+
+ @media(max-width: $screen-sm-max) {
+ padding-right: 20px;
+ }
+
+ &::before {
+ content: ' ';
+ display: block;
+ height: 1px;
+ overflow: hidden;
+ margin-bottom: 4px;
+ }
+
+ &::after {
+ content: ' ';
+ display: block;
+ height: 1px;
+ overflow: hidden;
+ margin-top: 20px;
+ }
+}
+
.settings-list-icon {
color: $gl-text-color-secondary;
font-size: $settings-icon-size;
diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss
index 03c75ce61f5..ab63225147f 100644
--- a/app/assets/stylesheets/pages/tree.scss
+++ b/app/assets/stylesheets/pages/tree.scss
@@ -138,11 +138,12 @@
.blob-commit-info {
list-style: none;
- background: $gray-light;
- padding: 16px 16px 16px 6px;
- border: 1px solid $border-color;
- border-bottom: none;
margin: 0;
+ padding: 0;
+}
+
+.blob-content-holder {
+ margin-top: $gl-padding;
}
.blob-upload-dropzone-previews {
diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss
index 6cc1cc8e263..136d0c79467 100644
--- a/app/assets/stylesheets/print.scss
+++ b/app/assets/stylesheets/print.scss
@@ -28,9 +28,6 @@ nav.navbar-collapse.collapse,
.profiler-results,
.tree-ref-holder,
.tree-holder .breadcrumb,
-.blob-commit-info,
-.file-title,
-.file-holder,
.nav,
.btn,
ul.notes-form,
@@ -43,6 +40,11 @@ ul.notes-form,
display: none!important;
}
+pre {
+ page-break-before: avoid;
+ page-break-inside: auto;
+}
+
.page-gutter {
padding-top: 0;
padding-left: 0;
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 152d7baad49..75fb19e815f 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -149,6 +149,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:version_check_enabled,
:terminal_max_session_time,
:polling_interval_multiplier,
+ :prometheus_metrics_enabled,
:usage_ping_enabled,
disabled_oauth_sign_in_sources: [],
diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
index 9c9f420c1e0..434ff6b2a62 100644
--- a/app/controllers/admin/applications_controller.rb
+++ b/app/controllers/admin/applications_controller.rb
@@ -39,7 +39,7 @@ class Admin::ApplicationsController < Admin::ApplicationController
def destroy
@application.destroy
- redirect_to admin_applications_url, notice: 'Application was successfully destroyed.'
+ redirect_to admin_applications_url, status: 302, notice: 'Application was successfully destroyed.'
end
private
diff --git a/app/controllers/admin/conversational_development_index_controller.rb b/app/controllers/admin/conversational_development_index_controller.rb
new file mode 100644
index 00000000000..921169d3e2b
--- /dev/null
+++ b/app/controllers/admin/conversational_development_index_controller.rb
@@ -0,0 +1,5 @@
+class Admin::ConversationalDevelopmentIndexController < Admin::ApplicationController
+ def show
+ @metric = ConversationalDevelopmentIndex::Metric.order(:created_at).last&.present
+ end
+end
diff --git a/app/controllers/admin/deploy_keys_controller.rb b/app/controllers/admin/deploy_keys_controller.rb
index 4f6a7e9e2cb..e5cba774dcb 100644
--- a/app/controllers/admin/deploy_keys_controller.rb
+++ b/app/controllers/admin/deploy_keys_controller.rb
@@ -1,6 +1,6 @@
class Admin::DeployKeysController < Admin::ApplicationController
before_action :deploy_keys, only: [:index]
- before_action :deploy_key, only: [:destroy]
+ before_action :deploy_key, only: [:destroy, :edit, :update]
def index
end
@@ -10,12 +10,24 @@ class Admin::DeployKeysController < Admin::ApplicationController
end
def create
- @deploy_key = deploy_keys.new(deploy_key_params.merge(user: current_user))
+ @deploy_key = deploy_keys.new(create_params.merge(user: current_user))
if @deploy_key.save
redirect_to admin_deploy_keys_path
else
- render "new"
+ render 'new'
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ if deploy_key.update_attributes(update_params)
+ flash[:notice] = 'Deploy key was successfully updated.'
+ redirect_to admin_deploy_keys_path
+ else
+ render 'edit'
end
end
@@ -23,7 +35,7 @@ class Admin::DeployKeysController < Admin::ApplicationController
deploy_key.destroy
respond_to do |format|
- format.html { redirect_to admin_deploy_keys_path }
+ format.html { redirect_to admin_deploy_keys_path, status: 302 }
format.json { head :ok }
end
end
@@ -38,7 +50,11 @@ class Admin::DeployKeysController < Admin::ApplicationController
@deploy_keys ||= DeployKey.are_public
end
- def deploy_key_params
+ def create_params
params.require(:deploy_key).permit(:key, :title, :can_push)
end
+
+ def update_params
+ params.require(:deploy_key).permit(:title, :can_push)
+ end
end
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index 5885b3543bb..2ce26de1768 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -43,19 +43,22 @@ class Admin::GroupsController < Admin::ApplicationController
end
def members_update
- status = Members::CreateService.new(@group, current_user, params).execute
+ member_params = params.permit(:user_ids, :access_level, :expires_at)
+ result = Members::CreateService.new(@group, current_user, member_params.merge(limit: -1)).execute
- if status
+ if result[:status] == :success
redirect_to [:admin, @group], notice: 'Users were successfully added.'
else
- redirect_to [:admin, @group], alert: 'No users specified.'
+ redirect_to [:admin, @group], alert: result[:message]
end
end
def destroy
Groups::DestroyService.new(@group, current_user).async_execute
- redirect_to admin_groups_path, alert: "Group '#{@group.name}' was scheduled for deletion."
+ redirect_to admin_groups_path,
+ status: 302,
+ alert: "Group '#{@group.name}' was scheduled for deletion."
end
private
diff --git a/app/controllers/admin/hook_logs_controller.rb b/app/controllers/admin/hook_logs_controller.rb
new file mode 100644
index 00000000000..aa069b89563
--- /dev/null
+++ b/app/controllers/admin/hook_logs_controller.rb
@@ -0,0 +1,29 @@
+class Admin::HookLogsController < Admin::ApplicationController
+ include HooksExecution
+
+ before_action :hook, only: [:show, :retry]
+ before_action :hook_log, only: [:show, :retry]
+
+ respond_to :html
+
+ def show
+ end
+
+ def retry
+ status, message = hook.execute(hook_log.request_data, hook_log.trigger)
+
+ set_hook_execution_notice(status, message)
+
+ redirect_to edit_admin_hook_path(@hook)
+ end
+
+ private
+
+ def hook
+ @hook ||= SystemHook.find(params[:hook_id])
+ end
+
+ def hook_log
+ @hook_log ||= hook.web_hook_logs.find(params[:id])
+ end
+end
diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb
index a119934febc..054c3500b35 100644
--- a/app/controllers/admin/hooks_controller.rb
+++ b/app/controllers/admin/hooks_controller.rb
@@ -1,5 +1,7 @@
class Admin::HooksController < Admin::ApplicationController
- before_action :hook, only: :edit
+ include HooksExecution
+
+ before_action :hook_logs, only: :edit
def index
@hooks = SystemHook.all
@@ -32,19 +34,13 @@ class Admin::HooksController < Admin::ApplicationController
def destroy
hook.destroy
- redirect_to admin_hooks_path
+ redirect_to admin_hooks_path, status: 302
end
def test
- data = {
- event_name: "project_create",
- name: "Ruby",
- path: "ruby",
- project_id: 1,
- owner_name: "Someone",
- owner_email: "example@gitlabhq.com"
- }
- hook.execute(data, 'system_hooks')
+ status, message = hook.execute(sample_hook_data, 'system_hooks')
+
+ set_hook_execution_notice(status, message)
redirect_back_or_default
end
@@ -55,13 +51,30 @@ class Admin::HooksController < Admin::ApplicationController
@hook ||= SystemHook.find(params[:id])
end
+ def hook_logs
+ @hook_logs ||=
+ Kaminari.paginate_array(hook.web_hook_logs.order(created_at: :desc)).page(params[:page])
+ end
+
def hook_params
params.require(:hook).permit(
:enable_ssl_verification,
:push_events,
:tag_push_events,
+ :repository_update_events,
:token,
:url
)
end
+
+ def sample_hook_data
+ {
+ event_name: "project_create",
+ name: "Ruby",
+ path: "ruby",
+ project_id: 1,
+ owner_name: "Someone",
+ owner_email: "example@gitlabhq.com"
+ }
+ end
end
diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb
index 79a53556f0a..43b4e3a2cc3 100644
--- a/app/controllers/admin/identities_controller.rb
+++ b/app/controllers/admin/identities_controller.rb
@@ -36,9 +36,9 @@ class Admin::IdentitiesController < Admin::ApplicationController
def destroy
if @identity.destroy
RepairLdapBlockedUserService.new(@user).execute
- redirect_to admin_user_identities_path(@user), notice: 'User identity was successfully removed.'
+ redirect_to admin_user_identities_path(@user), status: 302, notice: 'User identity was successfully removed.'
else
- redirect_to admin_user_identities_path(@user), alert: 'Failed to remove user identity.'
+ redirect_to admin_user_identities_path(@user), status: 302, alert: 'Failed to remove user identity.'
end
end
diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb
index 8e7adc06584..39dbf85f6c0 100644
--- a/app/controllers/admin/impersonations_controller.rb
+++ b/app/controllers/admin/impersonations_controller.rb
@@ -11,7 +11,7 @@ class Admin::ImpersonationsController < Admin::ApplicationController
session[:impersonator_id] = nil
- redirect_to admin_user_path(original_user)
+ redirect_to admin_user_path(original_user), status: 302
end
private
diff --git a/app/controllers/admin/builds_controller.rb b/app/controllers/admin/jobs_controller.rb
index 88f3c0e2fd4..5162273ef8a 100644
--- a/app/controllers/admin/builds_controller.rb
+++ b/app/controllers/admin/jobs_controller.rb
@@ -1,4 +1,4 @@
-class Admin::BuildsController < Admin::ApplicationController
+class Admin::JobsController < Admin::ApplicationController
def index
@scope = params[:scope]
@all_builds = Ci::Build
@@ -20,6 +20,6 @@ class Admin::BuildsController < Admin::ApplicationController
def cancel_all
Ci::Build.running_or_pending.each(&:cancel)
- redirect_to admin_builds_path
+ redirect_to admin_jobs_path
end
end
diff --git a/app/controllers/admin/keys_controller.rb b/app/controllers/admin/keys_controller.rb
index 054bb52b696..0b76193a90e 100644
--- a/app/controllers/admin/keys_controller.rb
+++ b/app/controllers/admin/keys_controller.rb
@@ -15,9 +15,9 @@ class Admin::KeysController < Admin::ApplicationController
respond_to do |format|
if key.destroy
- format.html { redirect_to [:admin, user], notice: 'User key was successfully removed.' }
+ format.html { redirect_to keys_admin_user_path(user), status: 302, notice: 'User key was successfully removed.' }
else
- format.html { redirect_to [:admin, user], alert: 'Failed to remove user key.' }
+ format.html { redirect_to keys_admin_user_path(user), status: 302, alert: 'Failed to remove user key.' }
end
end
end
diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb
index 4531657268c..cbc7a14ae83 100644
--- a/app/controllers/admin/labels_controller.rb
+++ b/app/controllers/admin/labels_controller.rb
@@ -41,7 +41,7 @@ class Admin::LabelsController < Admin::ApplicationController
respond_to do |format|
format.html do
- redirect_to(admin_labels_path, notice: 'Label was removed')
+ redirect_to admin_labels_path, status: 302, notice: 'Label was removed'
end
format.js
end
diff --git a/app/controllers/admin/runner_projects_controller.rb b/app/controllers/admin/runner_projects_controller.rb
index 70ac6a75434..7ed2de71028 100644
--- a/app/controllers/admin/runner_projects_controller.rb
+++ b/app/controllers/admin/runner_projects_controller.rb
@@ -18,7 +18,7 @@ class Admin::RunnerProjectsController < Admin::ApplicationController
runner = rp.runner
rp.destroy
- redirect_to admin_runner_path(runner)
+ redirect_to admin_runner_path(runner), status: 302
end
private
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 348641e5ecb..719893c0bc8 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -27,7 +27,7 @@ class Admin::RunnersController < Admin::ApplicationController
def destroy
@runner.destroy
- redirect_to admin_runners_path
+ redirect_to admin_runners_path, status: 302
end
def resume
diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb
index 1d66955bb71..d52d67a67a5 100644
--- a/app/controllers/admin/spam_logs_controller.rb
+++ b/app/controllers/admin/spam_logs_controller.rb
@@ -8,7 +8,9 @@ class Admin::SpamLogsController < Admin::ApplicationController
if params[:remove_user]
spam_log.remove_user(deleted_by: current_user)
- redirect_to admin_spam_logs_path, notice: "User #{spam_log.user.username} was successfully removed."
+ redirect_to admin_spam_logs_path,
+ status: 302,
+ notice: "User #{spam_log.user.username} was successfully removed."
else
spam_log.destroy
head :ok
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 563bcc65bd6..b09eef17c23 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -138,10 +138,10 @@ class Admin::UsersController < Admin::ApplicationController
end
def destroy
- DeleteUserWorker.perform_async(current_user.id, user.id)
+ user.delete_async(deleted_by: current_user, params: params.permit(:hard_delete))
respond_to do |format|
- format.html { redirect_to admin_users_path, notice: "The user is being deleted." }
+ format.html { redirect_to admin_users_path, status: 302, notice: "The user is being deleted." }
format.json { head :ok }
end
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 8ce9150e4a9..47ce21d238b 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base
include EnforcesTwoFactorAuthentication
before_action :authenticate_user_from_private_token!
+ before_action :authenticate_user_from_rss_token!
before_action :authenticate_user!
before_action :validate_user_service_ticket!
before_action :check_password_expiration
@@ -72,13 +73,20 @@ class ApplicationController < ActionController::Base
user = User.find_by_authentication_token(token) || User.find_by_personal_access_token(token)
- if user && can?(user, :log_in)
- # Notice we are passing store false, so the user is not
- # actually stored in the session and a token is needed
- # for every request. If you want the token to work as a
- # sign in token, you can simply remove store: false.
- sign_in user, store: false
- end
+ sessionless_sign_in(user)
+ end
+
+ # This filter handles authentication for atom request with an rss_token
+ def authenticate_user_from_rss_token!
+ return unless request.format.atom?
+
+ token = params[:rss_token].presence
+
+ return unless token.present?
+
+ user = User.find_by_rss_token(token)
+
+ sessionless_sign_in(user)
end
def log_exception(exception)
@@ -275,11 +283,17 @@ class ApplicationController < ActionController::Base
request.base_url
end
- def set_locale
- Gitlab::I18n.set_locale(current_user)
+ def set_locale(&block)
+ Gitlab::I18n.with_user_locale(current_user, &block)
+ end
- yield
- ensure
- Gitlab::I18n.reset_locale
+ def sessionless_sign_in(user)
+ if user && can?(user, :log_in)
+ # Notice we are passing store false, so the user is not
+ # actually stored in the session and a token is needed
+ # for every request. If you want the token to work as a
+ # sign in token, you can simply remove store: false.
+ sign_in user, store: false
+ end
end
end
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index f94f88305a4..fe331a883c1 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -9,7 +9,7 @@ class AutocompleteController < ApplicationController
@users = @users.where.not(id: params[:skip_users]) if params[:skip_users].present?
@users = @users.active
@users = @users.reorder(:name)
- @users = @users.page(params[:page])
+ @users = @users.page(params[:page]).per(params[:per_page])
if params[:todo_filter].present? && current_user
@users = @users.todo_authors(current_user.id, params[:todo_state_filter])
@@ -41,7 +41,7 @@ class AutocompleteController < ApplicationController
no_project = {
id: 0,
- name_with_namespace: 'No project',
+ name_with_namespace: 'No project'
}
projects.unshift(no_project) unless params[:offset_id].present?
diff --git a/app/controllers/concerns/diff_for_path.rb b/app/controllers/concerns/diff_for_path.rb
index 1efa9fe060f..d5388c4cd20 100644
--- a/app/controllers/concerns/diff_for_path.rb
+++ b/app/controllers/concerns/diff_for_path.rb
@@ -8,17 +8,6 @@ module DiffForPath
return render_404 unless diff_file
- diff_commit = commit_for_diff(diff_file)
- blob = diff_file.blob(diff_commit)
-
- locals = {
- diff_file: diff_file,
- diff_commit: diff_commit,
- diff_refs: diffs.diff_refs,
- blob: blob,
- project: project
- }
-
- render json: { html: view_to_html_string('projects/diffs/_content', locals) }
+ render json: { html: view_to_html_string('projects/diffs/_content', diff_file: diff_file) }
end
end
diff --git a/app/controllers/concerns/hooks_execution.rb b/app/controllers/concerns/hooks_execution.rb
new file mode 100644
index 00000000000..846cd60518f
--- /dev/null
+++ b/app/controllers/concerns/hooks_execution.rb
@@ -0,0 +1,15 @@
+module HooksExecution
+ extend ActiveSupport::Concern
+
+ private
+
+ def set_hook_execution_notice(status, message)
+ if status && status >= 200 && status < 400
+ flash[:notice] = "Hook executed successfully: HTTP #{status}"
+ elsif status
+ flash[:alert] = "Hook executed successfully but returned HTTP #{status} #{message}"
+ else
+ flash[:alert] = "Hook execution failed: #{message}"
+ end
+ end
+end
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 4cf645d6341..0c3b68a7ac3 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -14,7 +14,16 @@ module IssuableActions
name = issuable.human_class_name
flash[:notice] = "The #{name} was successfully deleted."
- redirect_to polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class])
+ index_path = polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class])
+
+ respond_to do |format|
+ format.html { redirect_to index_path }
+ format.json do
+ render json: {
+ web_url: index_path
+ }
+ end
+ end
end
def bulk_update
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index 6df2c068745..650ec1e326a 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -47,7 +47,7 @@ module IssuableCollections
end
def merge_requests_collection
- merge_requests_finder.execute.preload(:source_project, :target_project, :author, :assignee, :labels, :milestone, :merge_request_diff, target_project: :namespace)
+ merge_requests_finder.execute.preload(:source_project, :target_project, :author, :assignee, :labels, :milestone, :merge_request_diff, :head_pipeline, target_project: :namespace)
end
def issues_finder
diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb
index ed22b1e5470..2b6afaa6233 100644
--- a/app/controllers/concerns/lfs_request.rb
+++ b/app/controllers/concerns/lfs_request.rb
@@ -23,7 +23,7 @@ module LfsRequest
render(
json: {
message: 'Git LFS is not enabled on this GitLab server, contact your admin.',
- documentation_url: help_url,
+ documentation_url: help_url
},
status: 501
)
@@ -48,7 +48,7 @@ module LfsRequest
render(
json: {
message: 'Access forbidden. Check your access level.',
- documentation_url: help_url,
+ documentation_url: help_url
},
content_type: "application/vnd.git-lfs+json",
status: 403
@@ -59,7 +59,7 @@ module LfsRequest
render(
json: {
message: 'Not found.',
- documentation_url: help_url,
+ documentation_url: help_url
},
content_type: "application/vnd.git-lfs+json",
status: 404
@@ -106,4 +106,8 @@ module LfsRequest
def objects
@objects ||= (params[:objects] || []).to_a
end
+
+ def has_authentication_ability?(capability)
+ (authentication_abilities || []).include?(capability)
+ end
end
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index b1bacc8ffe5..cefb9b4e766 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -2,14 +2,15 @@ module MembershipActions
extend ActiveSupport::Concern
def create
- status = Members::CreateService.new(membershipable, current_user, params).execute
+ create_params = params.permit(:user_ids, :access_level, :expires_at)
+ result = Members::CreateService.new(membershipable, current_user, create_params).execute
redirect_url = members_page_url
- if status
+ if result[:status] == :success
redirect_to redirect_url, notice: 'Users were successfully added.'
else
- redirect_to redirect_url, alert: 'No users specified.'
+ redirect_to redirect_url, alert: result[:message]
end
end
diff --git a/app/controllers/concerns/renders_blob.rb b/app/controllers/concerns/renders_blob.rb
index 9faf68e6d97..54dcd7c61ce 100644
--- a/app/controllers/concerns/renders_blob.rb
+++ b/app/controllers/concerns/renders_blob.rb
@@ -3,19 +3,22 @@ module RendersBlob
def render_blob_json(blob)
viewer =
- if params[:viewer] == 'rich'
+ case params[:viewer]
+ when 'rich'
blob.rich_viewer
+ when 'auxiliary'
+ blob.auxiliary_viewer
else
blob.simple_viewer
end
return render_404 unless viewer
render json: {
- html: view_to_html_string("projects/blob/_viewer", viewer: viewer, load_asynchronously: false)
+ html: view_to_html_string("projects/blob/_viewer", viewer: viewer, load_async: false)
}
end
- def override_max_blob_size(blob)
- blob.override_max_size! if params[:override_max_size] == 'true'
+ def conditionally_expand_blob(blob)
+ blob.expand! if params[:expanded] == 'true'
end
end
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index 5a1efcab1a3..3d49ea97591 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -8,7 +8,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
@projects = load_projects(params.merge(non_public: true)).page(params[:page])
respond_to do |format|
- format.html { @last_push = current_user.recent_push }
+ format.html
format.atom do
load_events
render layout: false
@@ -25,7 +25,6 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
@projects = load_projects(params.merge(starred: true)).
includes(:forked_from_project, :tags).page(params[:page])
- @last_push = current_user.recent_push
@groups = []
respond_to do |format|
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 4d7d45787fc..623392c1240 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -15,7 +15,11 @@ class Dashboard::TodosController < Dashboard::ApplicationController
TodoService.new.mark_todos_as_done_by_ids([params[:id]], current_user)
respond_to do |format|
- format.html { redirect_to dashboard_todos_path, notice: 'Todo was successfully marked as done.' }
+ format.html do
+ redirect_to dashboard_todos_path,
+ status: 302,
+ notice: 'Todo was successfully marked as done.'
+ end
format.js { head :ok }
format.json { render json: todos_counts }
end
@@ -25,7 +29,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
updated_ids = TodoService.new.mark_todos_as_done(@todos, current_user)
respond_to do |format|
- format.html { redirect_to dashboard_todos_path, notice: 'All todos were marked as done.' }
+ format.html { redirect_to dashboard_todos_path, status: 302, notice: 'All todos were marked as done.' }
format.js { head :ok }
format.json { render json: todos_counts.merge(updated_ids: updated_ids) }
end
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 79d420a32d3..f9c31920302 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -9,8 +9,6 @@ class DashboardController < Dashboard::ApplicationController
respond_to :html
def activity
- @last_push = current_user.recent_push
-
respond_to do |format|
format.html
@@ -26,7 +24,7 @@ class DashboardController < Dashboard::ApplicationController
def load_events
projects =
if params[:filter] == "starred"
- current_user.viewable_starred_projects
+ ProjectsFinder.new(current_user: current_user, params: { starred: true }).execute
else
current_user.authorized_projects
end
diff --git a/app/controllers/groups/avatars_controller.rb b/app/controllers/groups/avatars_controller.rb
index ad2c20b42db..735915abdaa 100644
--- a/app/controllers/groups/avatars_controller.rb
+++ b/app/controllers/groups/avatars_controller.rb
@@ -5,6 +5,6 @@ class Groups::AvatarsController < Groups::ApplicationController
@group.remove_avatar!
@group.save
- redirect_to edit_group_path(@group)
+ redirect_to edit_group_path(@group), status: 302
end
end
diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb
index 3fa0516fb0c..dda59262483 100644
--- a/app/controllers/groups/labels_controller.rb
+++ b/app/controllers/groups/labels_controller.rb
@@ -54,7 +54,7 @@ class Groups::LabelsController < Groups::ApplicationController
respond_to do |format|
format.html do
- redirect_to group_labels_path(@group), notice: 'Label was removed'
+ redirect_to group_labels_path(@group), status: 302, notice: 'Label was removed'
end
format.js
end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index ebbcc10dd35..c08943d993a 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -101,7 +101,7 @@ class GroupsController < Groups::ApplicationController
def destroy
Groups::DestroyService.new(@group, current_user).async_execute
- redirect_to root_path, alert: "Group '#{@group.name}' was scheduled for deletion."
+ redirect_to root_path, status: 302, alert: "Group '#{@group.name}' was scheduled for deletion."
end
protected
@@ -167,14 +167,13 @@ class GroupsController < Groups::ApplicationController
def user_actions
if current_user
- @last_push = current_user.recent_push
@notification_setting = current_user.notification_settings_for(group)
end
end
def build_canonical_path(group)
return group_path(group) if action_name == 'show' # root group path
-
+
params[:id] = group.to_param
url_for(params)
diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb
index df0fc3132ed..abc832e6ddc 100644
--- a/app/controllers/health_controller.rb
+++ b/app/controllers/health_controller.rb
@@ -5,7 +5,7 @@ class HealthController < ActionController::Base
CHECKS = [
Gitlab::HealthChecks::DbCheck,
Gitlab::HealthChecks::RedisCheck,
- Gitlab::HealthChecks::FsShardsCheck,
+ Gitlab::HealthChecks::FsShardsCheck
].freeze
def readiness
@@ -20,25 +20,8 @@ class HealthController < ActionController::Base
render_check_results(results)
end
- def metrics
- results = CHECKS.flat_map(&:metrics)
-
- response = results.map(&method(:metric_to_prom_line)).join("\n")
-
- render text: response, content_type: 'text/plain; version=0.0.4'
- end
-
private
- def metric_to_prom_line(metric)
- labels = metric.labels&.map { |key, value| "#{key}=\"#{value}\"" }&.join(',') || ''
- if labels.empty?
- "#{metric.name} #{metric.value}"
- else
- "#{metric.name}{#{labels}} #{metric.value}"
- end
- end
-
def render_check_results(results)
flattened = results.flat_map do |name, result|
if result.is_a?(Gitlab::HealthChecks::Result)
diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb
index 3109439b2ff..c585d26df77 100644
--- a/app/controllers/jwt_controller.rb
+++ b/app/controllers/jwt_controller.rb
@@ -4,7 +4,7 @@ class JwtController < ApplicationController
before_action :authenticate_project_or_user
SERVICES = {
- Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService,
+ Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService
}.freeze
def auth
@@ -25,8 +25,10 @@ class JwtController < ApplicationController
authenticate_with_http_basic do |login, password|
@authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, ip: request.ip)
- render_unauthorized unless @authentication_result.success? &&
- (@authentication_result.actor.nil? || @authentication_result.actor.is_a?(User))
+ if @authentication_result.failed? ||
+ (@authentication_result.actor.present? && !@authentication_result.actor.is_a?(User))
+ render_unauthorized
+ end
end
rescue Gitlab::Auth::MissingPersonalTokenError
render_missing_personal_token
diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb
new file mode 100644
index 00000000000..0e9a19c0b6f
--- /dev/null
+++ b/app/controllers/metrics_controller.rb
@@ -0,0 +1,21 @@
+class MetricsController < ActionController::Base
+ include RequiresHealthToken
+
+ protect_from_forgery with: :exception
+
+ before_action :validate_prometheus_metrics
+
+ def index
+ render text: metrics_service.metrics_text, content_type: 'text/plain; verssion=0.0.4'
+ end
+
+ private
+
+ def metrics_service
+ @metrics_service ||= MetricsService.new
+ end
+
+ def validate_prometheus_metrics
+ render_404 unless Gitlab::Metrics.prometheus_metrics_enabled?
+ end
+end
diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb
index 4193ac11399..656107d2b26 100644
--- a/app/controllers/oauth/authorized_applications_controller.rb
+++ b/app/controllers/oauth/authorized_applications_controller.rb
@@ -10,6 +10,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
Doorkeeper::AccessToken.revoke_all_for(params[:id], current_resource_owner)
end
- redirect_to applications_profile_url, notice: I18n.t(:notice, scope: [:doorkeeper, :flash, :authorized_applications, :destroy])
+ redirect_to applications_profile_url,
+ status: 302,
+ notice: I18n.t(:notice, scope: [:doorkeeper, :flash, :authorized_applications, :destroy])
end
end
diff --git a/app/controllers/profiles/avatars_controller.rb b/app/controllers/profiles/avatars_controller.rb
index daa51ae41df..933e0f3bceb 100644
--- a/app/controllers/profiles/avatars_controller.rb
+++ b/app/controllers/profiles/avatars_controller.rb
@@ -5,6 +5,6 @@ class Profiles::AvatarsController < Profiles::ApplicationController
@user.save
- redirect_to profile_path
+ redirect_to profile_path, status: 302
end
end
diff --git a/app/controllers/profiles/chat_names_controller.rb b/app/controllers/profiles/chat_names_controller.rb
index 6a1f468ba5a..2353f0840d6 100644
--- a/app/controllers/profiles/chat_names_controller.rb
+++ b/app/controllers/profiles/chat_names_controller.rb
@@ -39,7 +39,7 @@ class Profiles::ChatNamesController < Profiles::ApplicationController
flash[:alert] = "Could not delete chat nickname #{@chat_name.chat_name}."
end
- redirect_to profile_chat_names_path
+ redirect_to profile_chat_names_path, status: 302
end
private
diff --git a/app/controllers/profiles/emails_controller.rb b/app/controllers/profiles/emails_controller.rb
index 1c24c4db993..5655fb2ba0e 100644
--- a/app/controllers/profiles/emails_controller.rb
+++ b/app/controllers/profiles/emails_controller.rb
@@ -23,7 +23,7 @@ class Profiles::EmailsController < Profiles::ApplicationController
current_user.update_secondary_emails!
respond_to do |format|
- format.html { redirect_to profile_emails_url }
+ format.html { redirect_to profile_emails_url, status: 302 }
format.js { head :ok }
end
end
diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb
index e4452f46056..88f49da555a 100644
--- a/app/controllers/profiles/keys_controller.rb
+++ b/app/controllers/profiles/keys_controller.rb
@@ -26,7 +26,7 @@ class Profiles::KeysController < Profiles::ApplicationController
@key.destroy
respond_to do |format|
- format.html { redirect_to profile_keys_url }
+ format.html { redirect_to profile_keys_url, status: 302 }
format.js { head :ok }
end
end
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index 0abe7ea3c9b..f748d191ef4 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -38,7 +38,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
end
def set_index_vars
- @scopes = Gitlab::Auth::API_SCOPES
+ @scopes = Gitlab::Auth::AVAILABLE_SCOPES
@personal_access_token = finder.build
@inactive_personal_access_tokens = finder(state: 'inactive').execute
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index 0d891ef4004..5414142e2df 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -33,7 +33,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController
:color_scheme_id,
:layout,
:dashboard,
- :project_view,
+ :project_view
)
end
end
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index d3fa81cd623..313cdcd1c15 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -77,7 +77,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
def destroy
current_user.disable_two_factor!
- redirect_to profile_account_path
+ redirect_to profile_account_path, status: 302
end
def skip
diff --git a/app/controllers/profiles/u2f_registrations_controller.rb b/app/controllers/profiles/u2f_registrations_controller.rb
index c02fe85c3cc..e3d7737f44a 100644
--- a/app/controllers/profiles/u2f_registrations_controller.rb
+++ b/app/controllers/profiles/u2f_registrations_controller.rb
@@ -2,6 +2,6 @@ class Profiles::U2fRegistrationsController < Profiles::ApplicationController
def destroy
u2f_registration = current_user.u2f_registrations.find(params[:id])
u2f_registration.destroy
- redirect_to profile_two_factor_auth_path, notice: "Successfully deleted U2F device."
+ redirect_to profile_two_factor_auth_path, status: 302, notice: "Successfully deleted U2F device."
end
end
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 57e23cea00e..72f34930ca8 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -9,7 +9,7 @@ class ProfilesController < Profiles::ApplicationController
end
def update
- user_params.except!(:email) if @user.ldap_user?
+ user_params.except!(:email) if @user.external_email?
respond_to do |format|
if @user.update_attributes(user_params)
@@ -40,6 +40,14 @@ class ProfilesController < Profiles::ApplicationController
redirect_to profile_account_path
end
+ def reset_rss_token
+ if current_user.reset_rss_token!
+ flash[:notice] = "RSS token was successfully reset"
+ end
+
+ redirect_to profile_account_path
+ end
+
def audit_log
@events = AuditEvent.where(entity_type: "User", entity_id: current_user.id).
order("created_at DESC").
@@ -68,7 +76,7 @@ class ProfilesController < Profiles::ApplicationController
end
def user_params
- params.require(:user).permit(
+ @user_params ||= params.require(:user).permit(
:avatar,
:bio,
:email,
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index 1224e9503c9..ea036b1f705 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -27,7 +27,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
def file
blob = @entry.blob
- override_max_blob_size(blob)
+ conditionally_expand_blob(blob)
respond_to do |format|
format.html do
@@ -46,7 +46,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
def keep
build.keep_artifacts!
- redirect_to namespace_project_build_path(project.namespace, project, build)
+ redirect_to namespace_project_job_path(project.namespace, project, build)
end
def latest_succeeded
@@ -79,7 +79,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
end
def build_from_id
- project.builds.find_by(id: params[:build_id]) if params[:build_id]
+ project.builds.find_by(id: params[:job_id]) if params[:job_id]
end
def build_from_ref
diff --git a/app/controllers/projects/avatars_controller.rb b/app/controllers/projects/avatars_controller.rb
index 53788687076..21a403f3765 100644
--- a/app/controllers/projects/avatars_controller.rb
+++ b/app/controllers/projects/avatars_controller.rb
@@ -21,6 +21,6 @@ class Projects::AvatarsController < Projects::ApplicationController
@project.save
- redirect_to edit_project_path(@project)
+ redirect_to edit_project_path(@project), status: 302
end
end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 9489bbddfc4..7025c7a1de6 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -35,13 +35,15 @@ class Projects::BlobController < Projects::ApplicationController
end
def show
- override_max_blob_size(@blob)
+ conditionally_expand_blob(@blob)
respond_to do |format|
format.html do
environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit }
@environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
+ @last_commit = @repository.last_commit_for_path(@commit.id, @blob.path)
+
render 'show'
end
diff --git a/app/controllers/projects/boards/lists_controller.rb b/app/controllers/projects/boards/lists_controller.rb
index 67e3c9add81..ad53bb749a0 100644
--- a/app/controllers/projects/boards/lists_controller.rb
+++ b/app/controllers/projects/boards/lists_controller.rb
@@ -5,7 +5,9 @@ module Projects
before_action :authorize_read_list!, only: [:index]
def index
- render json: serialize_as_json(board.lists)
+ lists = ::Boards::Lists::ListService.new(project, current_user).execute(board)
+
+ render json: serialize_as_json(lists)
end
def create
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index 79de0d15642..d8ed470e461 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -74,10 +74,13 @@ class Projects::BranchesController < Projects::ApplicationController
def destroy
@branch_name = Addressable::URI.unescape(params[:id])
result = DeleteBranchService.new(project, current_user).execute(@branch_name)
+
respond_to do |format|
format.html do
- redirect_to namespace_project_branches_path(@project.namespace,
- @project), status: 303
+ flash_type = result[:status] == :error ? :alert : :notice
+ flash[flash_type] = result[:message]
+
+ redirect_to namespace_project_branches_path(@project.namespace, @project), status: 303
end
format.js { render nothing: true, status: result[:return_code] }
diff --git a/app/controllers/projects/build_artifacts_controller.rb b/app/controllers/projects/build_artifacts_controller.rb
new file mode 100644
index 00000000000..f34a198634e
--- /dev/null
+++ b/app/controllers/projects/build_artifacts_controller.rb
@@ -0,0 +1,55 @@
+class Projects::BuildArtifactsController < Projects::ApplicationController
+ include ExtractsPath
+ include RendersBlob
+
+ before_action :authorize_read_build!
+ before_action :extract_ref_name_and_path
+ before_action :validate_artifacts!
+
+ def download
+ redirect_to download_namespace_project_job_artifacts_path(project.namespace, project, job)
+ end
+
+ def browse
+ redirect_to browse_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path])
+ end
+
+ def file
+ redirect_to file_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path])
+ end
+
+ def raw
+ redirect_to raw_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path])
+ end
+
+ def latest_succeeded
+ redirect_to latest_succeeded_namespace_project_artifacts_path(project.namespace, project, job, ref_name_and_path: params[:ref_name_and_path], job: params[:job])
+ end
+
+ private
+
+ def validate_artifacts!
+ render_404 unless job && job.artifacts?
+ end
+
+ def extract_ref_name_and_path
+ return unless params[:ref_name_and_path]
+
+ @ref_name, @path = extract_ref(params[:ref_name_and_path])
+ end
+
+ def job
+ @job ||= job_from_id || job_from_ref
+ end
+
+ def job_from_id
+ project.builds.find_by(id: params[:build_id]) if params[:build_id]
+ end
+
+ def job_from_ref
+ return unless @ref_name
+
+ jobs = project.latest_successful_builds_for(@ref_name)
+ jobs.find_by(name: params[:job])
+ end
+end
diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb
index dfaaea71b9c..1334a231788 100644
--- a/app/controllers/projects/builds_controller.rb
+++ b/app/controllers/projects/builds_controller.rb
@@ -1,131 +1,21 @@
class Projects::BuildsController < Projects::ApplicationController
- before_action :build, except: [:index, :cancel_all]
-
- before_action :authorize_read_build!,
- only: [:index, :show, :status, :raw, :trace]
- before_action :authorize_update_build!,
- except: [:index, :show, :status, :raw, :trace, :cancel_all]
-
- layout 'project'
+ before_action :authorize_read_build!
def index
- @scope = params[:scope]
- @all_builds = project.builds.relevant
- @builds = @all_builds.order('created_at DESC')
- @builds =
- case @scope
- when 'pending'
- @builds.pending.reverse_order
- when 'running'
- @builds.running.reverse_order
- when 'finished'
- @builds.finished
- else
- @builds
- end
- @builds = @builds.includes([
- { pipeline: :project },
- :project,
- :tags
- ])
- @builds = @builds.page(params[:page]).per(30)
- end
-
- def cancel_all
- return access_denied! unless can?(current_user, :update_build, project)
-
- @project.builds.running_or_pending.each do |build|
- build.cancel if can?(current_user, :update_build, build)
- end
-
- redirect_to namespace_project_builds_path(project.namespace, project)
+ redirect_to namespace_project_jobs_path(project.namespace, project)
end
def show
- @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
- @builds = @builds.where("id not in (?)", @build.id)
- @pipeline = @build.pipeline
- end
-
- def trace
- build.trace.read do |stream|
- respond_to do |format|
- format.json do
- result = {
- id: @build.id, status: @build.status, complete: @build.complete?
- }
-
- if stream.valid?
- stream.limit
- state = params[:state].presence
- trace = stream.html_with_state(state)
- result.merge!(trace.to_h)
- end
-
- render json: result
- end
- end
- end
- end
-
- def retry
- return respond_422 unless @build.retryable?
-
- build = Ci::Build.retry(@build, current_user)
- redirect_to build_path(build)
- end
-
- def play
- return respond_422 unless @build.playable?
-
- build = @build.play(current_user)
- redirect_to build_path(build)
- end
-
- def cancel
- return respond_422 unless @build.cancelable?
-
- @build.cancel
- redirect_to build_path(@build)
- end
-
- def status
- render json: BuildSerializer
- .new(project: @project, current_user: @current_user)
- .represent_status(@build)
- end
-
- def erase
- if @build.erase(erased_by: current_user)
- redirect_to namespace_project_build_path(project.namespace, project, @build),
- notice: "Build has been successfully erased!"
- else
- respond_422
- end
+ redirect_to namespace_project_job_path(project.namespace, project, job)
end
def raw
- build.trace.read do |stream|
- if stream.file?
- send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
- else
- render_404
- end
- end
+ redirect_to raw_namespace_project_job_path(project.namespace, project, job)
end
private
- def authorize_update_build!
- return access_denied! unless can?(current_user, :update_build, build)
- end
-
- def build
- @build ||= project.builds.find(params[:id])
- .present(current_user: current_user)
- end
-
- def build_path(build)
- namespace_project_build_path(build.project.namespace, build.project, build)
+ def job
+ @job ||= project.builds.find(params[:id])
end
end
diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb
index 008d2f5815f..88dd600e5fe 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -51,13 +51,9 @@ class Projects::CompareController < Projects::ApplicationController
if @compare
@commits = @compare.commits
- @start_commit = @compare.start_commit
- @commit = @compare.commit
- @base_commit = @compare.base_commit
-
@diffs = @compare.diffs(diff_options)
- environment_params = @repository.branch_exists?(@head_ref) ? { ref: @head_ref } : { commit: @commit }
+ environment_params = @repository.branch_exists?(@head_ref) ? { ref: @head_ref } : { commit: @compare.commit }
@environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
@diff_notes_disabled = true
diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb
index f27089b8590..7f1469e107d 100644
--- a/app/controllers/projects/deploy_keys_controller.rb
+++ b/app/controllers/projects/deploy_keys_controller.rb
@@ -4,6 +4,7 @@ class Projects::DeployKeysController < Projects::ApplicationController
# Authorize
before_action :authorize_admin_project!
+ before_action :authorize_update_deploy_key!, only: [:edit, :update]
layout "project_settings"
@@ -21,7 +22,7 @@ class Projects::DeployKeysController < Projects::ApplicationController
end
def create
- @key = DeployKey.new(deploy_key_params.merge(user: current_user))
+ @key = DeployKey.new(create_params.merge(user: current_user))
unless @key.valid? && @project.deploy_keys << @key
flash[:alert] = @key.errors.full_messages.join(', ').html_safe
@@ -29,6 +30,18 @@ class Projects::DeployKeysController < Projects::ApplicationController
redirect_to_repository_settings(@project)
end
+ def edit
+ end
+
+ def update
+ if deploy_key.update_attributes(update_params)
+ flash[:notice] = 'Deploy key was successfully updated.'
+ redirect_to_repository_settings(@project)
+ else
+ render 'edit'
+ end
+ end
+
def enable
Projects::EnableDeployKeyService.new(@project, current_user, params).execute
@@ -52,7 +65,19 @@ class Projects::DeployKeysController < Projects::ApplicationController
protected
- def deploy_key_params
+ def deploy_key
+ @deploy_key ||= @project.deploy_keys.find(params[:id])
+ end
+
+ def create_params
params.require(:deploy_key).permit(:key, :title, :can_push)
end
+
+ def update_params
+ params.require(:deploy_key).permit(:title, :can_push)
+ end
+
+ def authorize_update_deploy_key!
+ access_denied! unless can?(current_user, :update_deploy_key, deploy_key)
+ end
end
diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb
index f06a4d943f3..6644deb49c9 100644
--- a/app/controllers/projects/deployments_controller.rb
+++ b/app/controllers/projects/deployments_controller.rb
@@ -11,13 +11,15 @@ class Projects::DeploymentsController < Projects::ApplicationController
end
def metrics
- @metrics = deployment.metrics(1.hour)
-
+ return render_404 unless deployment.has_metrics?
+ @metrics = deployment.metrics
if @metrics&.any?
render json: @metrics, status: :ok
else
head :no_content
end
+ rescue NotImplementedError
+ render_404
end
private
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index fd57afbd05f..4630f451445 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -15,6 +15,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
+ Gitlab::PollingInterval.set_header(response, interval: 3_000)
+
render json: {
environments: EnvironmentSerializer
.new(project: @project, current_user: @current_user)
@@ -31,6 +33,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def folder
folder_environments = project.environments.where(environment_type: params[:id])
@environments = folder_environments.with_state(params[:scope] || :available)
+ .order(:name)
respond_to do |format|
format.html
diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb
index 9a1bf037a95..7f3205a8001 100644
--- a/app/controllers/projects/git_http_client_controller.rb
+++ b/app/controllers/projects/git_http_client_controller.rb
@@ -128,32 +128,10 @@ class Projects::GitHttpClientController < Projects::ApplicationController
@authentication_result = Gitlab::Auth.find_for_git_client(
login, password, project: project, ip: request.ip)
- return false unless @authentication_result.success?
-
- if download_request?
- authentication_has_download_access?
- else
- authentication_has_upload_access?
- end
+ @authentication_result.success?
end
def ci?
authentication_result.ci?(project)
end
-
- def authentication_has_download_access?
- has_authentication_ability?(:download_code) || has_authentication_ability?(:build_download_code)
- end
-
- def authentication_has_upload_access?
- has_authentication_ability?(:push_code)
- end
-
- def has_authentication_ability?(capability)
- (authentication_abilities || []).include?(capability)
- end
-
- def authentication_project
- authentication_result.project
- end
end
diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb
index 9e4edcae101..b6b62da7b60 100644
--- a/app/controllers/projects/git_http_controller.rb
+++ b/app/controllers/projects/git_http_controller.rb
@@ -1,38 +1,27 @@
class Projects::GitHttpController < Projects::GitHttpClientController
include WorkhorseRequest
+ before_action :access_check
+
+ rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403
+ rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404
+
# GET /foo/bar.git/info/refs?service=git-upload-pack (git pull)
# GET /foo/bar.git/info/refs?service=git-receive-pack (git push)
def info_refs
- if upload_pack? && upload_pack_allowed?
- log_user_activity
-
- render_ok
- elsif receive_pack? && receive_pack_allowed?
- render_ok
- elsif http_blocked?
- render_http_not_allowed
- else
- render_denied
- end
+ log_user_activity if upload_pack?
+
+ render_ok
end
# POST /foo/bar.git/git-upload-pack (git pull)
def git_upload_pack
- if upload_pack? && upload_pack_allowed?
- render_ok
- else
- render_denied
- end
+ render_ok
end
# POST /foo/bar.git/git-receive-pack" (git push)
def git_receive_pack
- if receive_pack? && receive_pack_allowed?
- render_ok
- else
- render_denied
- end
+ render_ok
end
private
@@ -45,10 +34,6 @@ class Projects::GitHttpController < Projects::GitHttpClientController
git_command == 'git-upload-pack'
end
- def receive_pack?
- git_command == 'git-receive-pack'
- end
-
def git_command
if action_name == 'info_refs'
params[:service]
@@ -62,47 +47,27 @@ class Projects::GitHttpController < Projects::GitHttpClientController
render json: Gitlab::Workhorse.git_http_ok(repository, wiki?, user, action_name)
end
- def render_http_not_allowed
- render plain: access_check.message, status: :forbidden
+ def render_403(exception)
+ render plain: exception.message, status: :forbidden
end
- def render_denied
- if user && can?(user, :read_project, project)
- render plain: access_denied_message, status: :forbidden
- else
- # Do not leak information about project existence
- render_not_found
- end
- end
-
- def access_denied_message
- 'Access denied'
+ def render_404(exception)
+ render plain: exception.message, status: :not_found
end
- def upload_pack_allowed?
- return false unless Gitlab.config.gitlab_shell.upload_pack
-
- access_check.allowed? || ci?
+ def access
+ @access ||= access_klass.new(access_actor, project, 'http', authentication_abilities: authentication_abilities)
end
- def access
- @access ||= access_klass.new(user, project, 'http', authentication_abilities: authentication_abilities)
+ def access_actor
+ return user if user
+ return :ci if ci?
end
def access_check
# Use the magic string '_any' to indicate we do not know what the
# changes are. This is also what gitlab-shell does.
- @access_check ||= access.check(git_command, '_any')
- end
-
- def http_blocked?
- !access.protocol_allowed?
- end
-
- def receive_pack_allowed?
- return false unless Gitlab.config.gitlab_shell.receive_pack
-
- access_check.allowed?
+ access.check(git_command, '_any')
end
def access_klass
diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index 66b7bdbd988..deb33a2f0ff 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -36,7 +36,7 @@ class Projects::GroupLinksController < Projects::ApplicationController
respond_to do |format|
format.html do
- redirect_to namespace_project_settings_members_path(project.namespace, project)
+ redirect_to namespace_project_settings_members_path(project.namespace, project), status: 302
end
format.js { head :ok }
end
diff --git a/app/controllers/projects/hook_logs_controller.rb b/app/controllers/projects/hook_logs_controller.rb
new file mode 100644
index 00000000000..354f0d6db3a
--- /dev/null
+++ b/app/controllers/projects/hook_logs_controller.rb
@@ -0,0 +1,33 @@
+class Projects::HookLogsController < Projects::ApplicationController
+ include HooksExecution
+
+ before_action :authorize_admin_project!
+
+ before_action :hook, only: [:show, :retry]
+ before_action :hook_log, only: [:show, :retry]
+
+ respond_to :html
+
+ layout 'project_settings'
+
+ def show
+ end
+
+ def retry
+ status, message = hook.execute(hook_log.request_data, hook_log.trigger)
+
+ set_hook_execution_notice(status, message)
+
+ redirect_to edit_namespace_project_hook_path(@project.namespace, @project, @hook)
+ end
+
+ private
+
+ def hook
+ @hook ||= @project.hooks.find(params[:hook_id])
+ end
+
+ def hook_log
+ @hook_log ||= hook.web_hook_logs.find(params[:id])
+ end
+end
diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index 86d13a0d222..f5143280154 100644
--- a/app/controllers/projects/hooks_controller.rb
+++ b/app/controllers/projects/hooks_controller.rb
@@ -1,7 +1,9 @@
class Projects::HooksController < Projects::ApplicationController
+ include HooksExecution
+
# Authorize
before_action :authorize_admin_project!
- before_action :hook, only: :edit
+ before_action :hook_logs, only: :edit
respond_to :html
@@ -34,13 +36,7 @@ class Projects::HooksController < Projects::ApplicationController
if !@project.empty_repo?
status, message = TestHookService.new.execute(hook, current_user)
- if status && status >= 200 && status < 400
- flash[:notice] = "Hook executed successfully: HTTP #{status}"
- elsif status
- flash[:alert] = "Hook executed successfully but returned HTTP #{status} #{message}"
- else
- flash[:alert] = "Hook execution failed: #{message}"
- end
+ set_hook_execution_notice(status, message)
else
flash[:alert] = 'Hook execution failed. Ensure the project has commits.'
end
@@ -51,7 +47,7 @@ class Projects::HooksController < Projects::ApplicationController
def destroy
hook.destroy
- redirect_to namespace_project_settings_integrations_path(@project.namespace, @project)
+ redirect_to namespace_project_settings_integrations_path(@project.namespace, @project), status: 302
end
private
@@ -60,6 +56,11 @@ class Projects::HooksController < Projects::ApplicationController
@hook ||= @project.hooks.find(params[:id])
end
+ def hook_logs
+ @hook_logs ||=
+ Kaminari.paginate_array(hook.web_hook_logs.order(created_at: :desc)).page(params[:page])
+ end
+
def hook_params
params.require(:hook).permit(
:job_events,
diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb
index a1b84afcd91..4b143434ea5 100644
--- a/app/controllers/projects/imports_controller.rb
+++ b/app/controllers/projects/imports_controller.rb
@@ -14,14 +14,7 @@ class Projects::ImportsController < Projects::ApplicationController
@project.import_url = params[:project][:import_url]
if @project.save
- @project.reload
-
- if @project.import_failed?
- @project.import_retry
- else
- @project.import_start
- @project.add_import_job
- end
+ @project.reload.import_schedule
end
redirect_to namespace_project_import_path(@project.namespace, @project)
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 7b1e4a70232..8b1efd0c572 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -11,10 +11,10 @@ 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, :create_merge_request]
+ :related_branches, :can_create_branch, :realtime_changes, :create_merge_request]
# Allow read any issue
- before_action :authorize_read_issue!, only: [:show, :rendered_title]
+ before_action :authorize_read_issue!, only: [:show, :realtime_changes]
# Allow write(create) issue
before_action :authorize_create_issue!, only: [:new, :create]
@@ -148,10 +148,7 @@ class Projects::IssuesController < Projects::ApplicationController
format.json do
if @issue.valid?
- render json: @issue.to_json(methods: [:task_status, :task_status_short],
- include: { milestone: {},
- assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
- labels: { methods: :text_color } })
+ render json: IssueSerializer.new.represent(@issue)
else
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
end
@@ -199,7 +196,7 @@ class Projects::IssuesController < Projects::ApplicationController
end
end
- def rendered_title
+ def realtime_changes
Gitlab::PollingInterval.set_header(response, interval: 3_000)
response = {
@@ -207,8 +204,7 @@ class Projects::IssuesController < Projects::ApplicationController
title_text: @issue.title,
description: view_context.markdown_field(@issue, :description),
description_text: @issue.description,
- task_status: @issue.task_status,
- issue_number: @issue.iid,
+ task_status: @issue.task_status
}
if @issue.is_edited?
@@ -234,7 +230,7 @@ class Projects::IssuesController < Projects::ApplicationController
def issue
# The Sortable default scope causes performance issues when used with find_by
- @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take || redirect_old
+ @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take!
end
alias_method :subscribable_resource, :issue
alias_method :issuable, :issue
@@ -273,25 +269,10 @@ class Projects::IssuesController < Projects::ApplicationController
end
end
- # Since iids are implemented only in 6.1
- # user may navigate to issue page using old global ids.
- #
- # To prevent 404 errors we provide a redirect to correct iids until 7.0 release
- #
- def redirect_old
- issue = @project.issues.find_by(id: params[:id])
-
- if issue
- redirect_to issue_path(issue)
- else
- raise ActiveRecord::RecordNotFound.new
- end
- end
-
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: [], assignee_ids: [],
+ :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [], assignee_ids: []
)
end
@@ -300,7 +281,10 @@ class Projects::IssuesController < Projects::ApplicationController
notice = "Please sign in to create the new issue."
- store_location_for :user, request.fullpath
+ if request.get? && !request.xhr?
+ store_location_for :user, request.fullpath
+ end
+
redirect_to new_user_session_path, notice: notice
end
end
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
new file mode 100644
index 00000000000..cb4f46388fd
--- /dev/null
+++ b/app/controllers/projects/jobs_controller.rb
@@ -0,0 +1,142 @@
+class Projects::JobsController < Projects::ApplicationController
+ before_action :build, except: [:index, :cancel_all]
+
+ before_action :authorize_read_build!,
+ only: [:index, :show, :status, :raw, :trace]
+ before_action :authorize_update_build!,
+ except: [:index, :show, :status, :raw, :trace, :cancel_all]
+
+ layout 'project'
+
+ def index
+ @scope = params[:scope]
+ @all_builds = project.builds.relevant
+ @builds = @all_builds.order('created_at DESC')
+ @builds =
+ case @scope
+ when 'pending'
+ @builds.pending.reverse_order
+ when 'running'
+ @builds.running.reverse_order
+ when 'finished'
+ @builds.finished
+ else
+ @builds
+ end
+ @builds = @builds.includes([
+ { pipeline: :project },
+ :project,
+ :tags
+ ])
+ @builds = @builds.page(params[:page]).per(30)
+ end
+
+ def cancel_all
+ return access_denied! unless can?(current_user, :update_build, project)
+
+ @project.builds.running_or_pending.each do |build|
+ build.cancel if can?(current_user, :update_build, build)
+ end
+
+ redirect_to namespace_project_jobs_path(project.namespace, project)
+ end
+
+ def show
+ @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
+ @builds = @builds.where("id not in (?)", @build.id)
+ @pipeline = @build.pipeline
+
+ respond_to do |format|
+ format.html
+ format.json do
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
+
+ render json: BuildSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(@build, {}, BuildDetailsEntity)
+ end
+ end
+ end
+
+ def trace
+ build.trace.read do |stream|
+ respond_to do |format|
+ format.json do
+ result = {
+ id: @build.id, status: @build.status, complete: @build.complete?
+ }
+
+ if stream.valid?
+ stream.limit
+ state = params[:state].presence
+ trace = stream.html_with_state(state)
+ result.merge!(trace.to_h)
+ end
+
+ render json: result
+ end
+ end
+ end
+ end
+
+ def retry
+ return respond_422 unless @build.retryable?
+
+ build = Ci::Build.retry(@build, current_user)
+ redirect_to build_path(build)
+ end
+
+ def play
+ return respond_422 unless @build.playable?
+
+ build = @build.play(current_user)
+ redirect_to build_path(build)
+ end
+
+ def cancel
+ return respond_422 unless @build.cancelable?
+
+ @build.cancel
+ redirect_to build_path(@build)
+ end
+
+ def status
+ render json: BuildSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent_status(@build)
+ end
+
+ def erase
+ if @build.erase(erased_by: current_user)
+ redirect_to namespace_project_job_path(project.namespace, project, @build),
+ notice: "Build has been successfully erased!"
+ else
+ respond_422
+ end
+ end
+
+ def raw
+ build.trace.read do |stream|
+ if stream.file?
+ send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
+ else
+ render_404
+ end
+ end
+ end
+
+ private
+
+ def authorize_update_build!
+ return access_denied! unless can?(current_user, :update_build, build)
+ end
+
+ def build
+ @build ||= project.builds.find(params[:id])
+ .present(current_user: current_user)
+ end
+
+ def build_path(build)
+ namespace_project_job_path(build.project.namespace, build.project, build)
+ end
+end
diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb
index 71bfb7163da..ac151839f61 100644
--- a/app/controllers/projects/labels_controller.rb
+++ b/app/controllers/projects/labels_controller.rb
@@ -74,7 +74,9 @@ class Projects::LabelsController < Projects::ApplicationController
@label.destroy
@labels = find_labels
- redirect_to(namespace_project_labels_path(@project.namespace, @project), notice: 'Label was removed')
+ redirect_to namespace_project_labels_path(@project.namespace, @project),
+ status: 302,
+ notice: 'Label was removed'
end
def remove_priority
diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb
index 8a5a645ed0e..1b0d3aab3fa 100644
--- a/app/controllers/projects/lfs_api_controller.rb
+++ b/app/controllers/projects/lfs_api_controller.rb
@@ -22,7 +22,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController
render(
json: {
message: 'Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.',
- documentation_url: "#{Gitlab.config.gitlab.url}/help",
+ documentation_url: "#{Gitlab.config.gitlab.url}/help"
},
status: 501
)
@@ -55,7 +55,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController
else
object[:error] = {
code: 404,
- message: "Object does not exist on the server or you don't have permissions to access it",
+ message: "Object does not exist on the server or you don't have permissions to access it"
}
end
end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 0352065998b..314906b5f09 100755..100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -14,7 +14,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
]
before_action :validates_merge_request, only: [:show, :diffs, :commits, :pipelines]
before_action :define_show_vars, only: [:diffs, :commits, :conflicts, :conflict_for_path, :builds, :pipelines]
- before_action :define_commit_vars, only: [:diffs]
before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :conflict_for_path, :pipelines]
before_action :close_merge_request_without_source_project, only: [:show, :diffs, :commits, :builds, :pipelines]
before_action :check_if_can_be_merged, only: :show
@@ -130,8 +129,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@diff_notes_disabled = true
end
- define_commit_vars
-
render_diff_for_path(@diffs)
end
@@ -500,11 +497,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
end
- def define_commit_vars
- @commit = @merge_request.diff_head_commit
- @base_commit = @merge_request.diff_base_commit || @merge_request.likely_diff_base_commit
- end
-
def define_diff_vars
@merge_request_diff =
if params[:diff_id]
@@ -569,7 +561,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@source_project = merge_request.source_project
@commits = @merge_request.compare_commits.reverse
@commit = @merge_request.diff_head_commit
- @base_commit = @merge_request.diff_base_commit
@note_counts = Note.where(commit_id: @commits.map(&:id)).
group(:commit_id).count
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index c56bce19eee..ae16f69955a 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -80,7 +80,7 @@ class Projects::MilestonesController < Projects::ApplicationController
Milestones::DestroyService.new(project, current_user).execute(milestone)
respond_to do |format|
- format.html { redirect_to namespace_project_milestones_path }
+ format.html { redirect_to namespace_project_milestones_path, status: 302 }
format.js { head :ok }
end
end
diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb
index 93b2c180810..28b383e69eb 100644
--- a/app/controllers/projects/pages_controller.rb
+++ b/app/controllers/projects/pages_controller.rb
@@ -15,8 +15,9 @@ class Projects::PagesController < Projects::ApplicationController
respond_to do |format|
format.html do
- redirect_to(namespace_project_pages_path(@project.namespace, @project),
- notice: 'Pages were removed')
+ redirect_to namespace_project_pages_path(@project.namespace, @project),
+ status: 302,
+ notice: 'Pages were removed'
end
end
end
diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb
index 3a93977fd27..dbd011f6c5d 100644
--- a/app/controllers/projects/pages_domains_controller.rb
+++ b/app/controllers/projects/pages_domains_controller.rb
@@ -27,8 +27,9 @@ class Projects::PagesDomainsController < Projects::ApplicationController
respond_to do |format|
format.html do
- redirect_to(namespace_project_pages_path(@project.namespace, @project),
- notice: 'Domain was removed')
+ redirect_to namespace_project_pages_path(@project.namespace, @project),
+ status: 302,
+ notice: 'Domain was removed'
end
format.js
end
diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
index 1616b2cb6b8..2662a146968 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -49,9 +49,11 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
def destroy
if schedule.destroy
- redirect_to pipeline_schedules_path(@project)
+ redirect_to pipeline_schedules_path(@project), status: 302
else
- redirect_to pipeline_schedules_path(@project), alert: "Failed to remove the pipeline schedule"
+ redirect_to pipeline_schedules_path(@project),
+ status: 302,
+ alert: "Failed to remove the pipeline schedule"
end
end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 7fe3c3c116c..6223e7943f8 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -44,7 +44,7 @@ class Projects::PipelinesController < Projects::ApplicationController
all: @pipelines_count,
running: @running_count,
pending: @pending_count,
- finished: @finished_count,
+ finished: @finished_count
}
}
end
@@ -58,7 +58,7 @@ class Projects::PipelinesController < Projects::ApplicationController
def create
@pipeline = Ci::CreatePipelineService
.new(project, current_user, create_params)
- .execute(ignore_skip_ci: true, save_on_errors: false)
+ .execute(:web, ignore_skip_ci: true, save_on_errors: false)
if @pipeline.persisted?
redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline)
@@ -99,7 +99,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def stage
- @stage = pipeline.stage(params[:stage])
+ @stage = pipeline.legacy_stage(params[:stage])
return not_found unless @stage
respond_to do |format|
diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb
index ff50602831c..38a47651000 100644
--- a/app/controllers/projects/pipelines_settings_controller.rb
+++ b/app/controllers/projects/pipelines_settings_controller.rb
@@ -7,7 +7,7 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
def update
if @project.update_attributes(update_params)
- flash[:notice] = "CI/CD Pipelines settings for '#{@project.name}' were successfully updated."
+ flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated."
redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
else
render 'show'
diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb
index ba24fa9acfe..d1719f12072 100644
--- a/app/controllers/projects/protected_branches_controller.rb
+++ b/app/controllers/projects/protected_branches_controller.rb
@@ -19,7 +19,7 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController
def protected_ref_params
params.require(:protected_branch).permit(:name,
- merge_access_levels_attributes: [:access_level, :id],
- push_access_levels_attributes: [:access_level, :id])
+ merge_access_levels_attributes: access_level_attributes,
+ push_access_levels_attributes: access_level_attributes)
end
end
diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb
index 083a70968e5..b51bdf7aa78 100644
--- a/app/controllers/projects/protected_refs_controller.rb
+++ b/app/controllers/projects/protected_refs_controller.rb
@@ -44,4 +44,10 @@ class Projects::ProtectedRefsController < Projects::ApplicationController
format.js { head :ok }
end
end
+
+ protected
+
+ def access_level_attributes
+ %i(access_level id)
+ end
end
diff --git a/app/controllers/projects/protected_tags_controller.rb b/app/controllers/projects/protected_tags_controller.rb
index c61ddf145e6..a5dbd7e46ae 100644
--- a/app/controllers/projects/protected_tags_controller.rb
+++ b/app/controllers/projects/protected_tags_controller.rb
@@ -18,6 +18,6 @@ class Projects::ProtectedTagsController < Projects::ProtectedRefsController
end
def protected_ref_params
- params.require(:protected_tag).permit(:name, create_access_levels_attributes: [:access_level, :id])
+ params.require(:protected_tag).permit(:name, create_access_levels_attributes: access_level_attributes)
end
end
diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb
index 667f4870c7a..2a0b58fae7c 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -74,6 +74,6 @@ class Projects::RefsController < Projects::ApplicationController
private
def validate_ref_id
- return not_found! if params[:id].present? && params[:id] !~ Gitlab::Regex.git_reference_regex
+ return not_found! if params[:id].present? && params[:id] !~ Gitlab::PathRegex.git_reference_regex
end
end
diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb
index 17f391ba07f..98e78585be8 100644
--- a/app/controllers/projects/registry/repositories_controller.rb
+++ b/app/controllers/projects/registry/repositories_controller.rb
@@ -11,9 +11,11 @@ module Projects
def destroy
if image.destroy
redirect_to project_container_registry_path(@project),
+ status: 302,
notice: 'Image repository has been removed successfully!'
else
redirect_to project_container_registry_path(@project),
+ status: 302,
alert: 'Failed to remove image repository!'
end
end
diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb
index d689cade3ab..5050dba3aab 100644
--- a/app/controllers/projects/registry/tags_controller.rb
+++ b/app/controllers/projects/registry/tags_controller.rb
@@ -6,9 +6,11 @@ module Projects
def destroy
if tag.delete
redirect_to project_container_registry_path(@project),
+ status: 302,
notice: 'Registry tag has been removed successfully!'
else
redirect_to project_container_registry_path(@project),
+ status: 302,
alert: 'Failed to remove registry tag!'
end
end
diff --git a/app/controllers/projects/runner_projects_controller.rb b/app/controllers/projects/runner_projects_controller.rb
index 8267b14941d..3cb01405b05 100644
--- a/app/controllers/projects/runner_projects_controller.rb
+++ b/app/controllers/projects/runner_projects_controller.rb
@@ -22,6 +22,6 @@ class Projects::RunnerProjectsController < Projects::ApplicationController
runner_project = project.runner_projects.find(params[:id])
runner_project.destroy
- redirect_to runners_path(project)
+ redirect_to runners_path(project), status: 302
end
end
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index 8b50ea207a5..160e632648a 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -24,7 +24,7 @@ class Projects::RunnersController < Projects::ApplicationController
@runner.destroy
end
- redirect_to runners_path(@project)
+ redirect_to runners_path(@project), status: 302
end
def resume
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index f9d798d0455..704f8cc8a79 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -4,6 +4,7 @@ class Projects::ServicesController < Projects::ApplicationController
# Authorize
before_action :authorize_admin_project!
before_action :service, only: [:edit, :update, :test]
+ before_action :update_service, only: [:update, :test]
respond_to :html
@@ -13,36 +14,46 @@ class Projects::ServicesController < Projects::ApplicationController
end
def update
- @service.assign_attributes(service_params[:service])
if @service.save(context: :manual_change)
- redirect_to(
- edit_namespace_project_service_path(@project.namespace, @project, @service.to_param),
- notice: 'Successfully updated.'
- )
+ redirect_to(namespace_project_settings_integrations_path(@project.namespace, @project), notice: success_message)
else
render 'edit'
end
end
def test
- return render_404 unless @service.can_test?
+ message = {}
+
+ if @service.can_test?
+ data = @service.test_data(project, current_user)
+ outcome = @service.test(data)
- data = @service.test_data(project, current_user)
- outcome = @service.test(data)
+ unless outcome[:success]
+ message = { error: true, message: 'Test failed.', service_response: outcome[:result].to_s }
+ end
- if outcome[:success]
- message = { notice: 'We sent a request to the provided URL' }
+ status = :ok
else
- error_message = "We tried to send a request to the provided URL but an error occurred"
- error_message << ": #{outcome[:result]}" if outcome[:result].present?
- message = { alert: error_message }
+ status = :not_found
end
- redirect_back_or_default(options: message)
+ render json: message, status: status
end
private
+ def success_message
+ if @service.active?
+ "#{@service.title} activated."
+ else
+ "#{@service.title} settings saved, but not activated."
+ end
+ end
+
+ def update_service
+ @service.assign_attributes(service_params[:service])
+ end
+
def service
@service ||= @project.find_or_initialize_service(params[:id])
end
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index 3b2b0d9e502..8a8f8d6a27d 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -56,7 +56,7 @@ class Projects::SnippetsController < Projects::ApplicationController
def show
blob = @snippet.blob
- override_max_blob_size(blob)
+ conditionally_expand_blob(blob)
respond_to do |format|
format.html do
@@ -79,7 +79,7 @@ class Projects::SnippetsController < Projects::ApplicationController
@snippet.destroy
- redirect_to namespace_project_snippets_path(@project.namespace, @project)
+ redirect_to namespace_project_snippets_path(@project.namespace, @project), status: 302
end
protected
@@ -107,6 +107,6 @@ class Projects::SnippetsController < Projects::ApplicationController
end
def snippet_params
- params.require(:project_snippet).permit(:title, :content, :file_name, :private, :visibility_level)
+ params.require(:project_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description)
end
end
diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb
index e13f0bde315..afbea3e2b40 100644
--- a/app/controllers/projects/tags_controller.rb
+++ b/app/controllers/projects/tags_controller.rb
@@ -38,6 +38,8 @@ class Projects::TagsController < Projects::ApplicationController
redirect_to namespace_project_tag_path(@project.namespace, @project, @tag.name)
else
@error = result[:message]
+ @message = params[:message]
+ @release_description = params[:release_description]
render action: 'new'
end
end
@@ -48,7 +50,7 @@ class Projects::TagsController < Projects::ApplicationController
respond_to do |format|
if result[:status] == :success
format.html do
- redirect_to namespace_project_tags_path(@project.namespace, @project)
+ redirect_to namespace_project_tags_path(@project.namespace, @project), status: 303
end
format.js
@@ -57,7 +59,7 @@ class Projects::TagsController < Projects::ApplicationController
format.html do
redirect_to namespace_project_tags_path(@project.namespace, @project),
- alert: @error
+ alert: @error, status: 303
end
format.js do
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index 5e2182c883e..f8eb8e00a5d 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -24,6 +24,8 @@ class Projects::TreeController < Projects::ApplicationController
end
end
+ @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit
+
respond_to do |format|
format.html
# Disable cache so browser history works
@@ -48,7 +50,7 @@ class Projects::TreeController < Projects::ApplicationController
@dir_name = File.join(@path, params[:dir_name])
@commit_params = {
file_path: @dir_name,
- commit_message: params[:commit_message],
+ commit_message: params[:commit_message]
}
end
end
diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb
index afa56de920b..e86adddd77f 100644
--- a/app/controllers/projects/triggers_controller.rb
+++ b/app/controllers/projects/triggers_controller.rb
@@ -50,7 +50,7 @@ class Projects::TriggersController < Projects::ApplicationController
flash[:alert] = "Could not remove the trigger."
end
- redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project)
+ redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project), status: 302
end
private
diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb
index a4d1b1ee69b..50e25a00f03 100644
--- a/app/controllers/projects/variables_controller.rb
+++ b/app/controllers/projects/variables_controller.rb
@@ -36,12 +36,15 @@ class Projects::VariablesController < Projects::ApplicationController
@key = @project.variables.find(params[:id])
@key.destroy
- redirect_to namespace_project_settings_ci_cd_path(project.namespace, project), notice: 'Variable was successfully removed.'
+ redirect_to namespace_project_settings_ci_cd_path(project.namespace, project),
+ status: 302,
+ notice: 'Variable was successfully removed.'
end
private
def project_params
- params.require(:variable).permit([:id, :key, :value, :_destroy])
+ params.require(:variable)
+ .permit([:id, :key, :value, :protected, :_destroy])
end
end
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index 887d18dbec3..e54b90b8d52 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -85,10 +85,9 @@ class Projects::WikisController < Projects::ApplicationController
@page = @project_wiki.find_page(params[:id])
WikiPages::DestroyService.new(@project, current_user).execute(@page)
- redirect_to(
- namespace_project_wiki_path(@project.namespace, @project, :home),
- notice: "Page was successfully deleted"
- )
+ redirect_to namespace_project_wiki_path(@project.namespace, @project, :home),
+ status: 302,
+ notice: "Page was successfully deleted"
end
def git_access
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index ed1c9f1b620..38ed7d776a7 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -34,7 +34,7 @@ class ProjectsController < Projects::ApplicationController
redirect_to(
project_path(@project),
- notice: "Project '#{@project.name}' was successfully created."
+ notice: _("Project '%{project_name}' was successfully created.") % { project_name: @project.name }
)
else
render 'new'
@@ -49,7 +49,7 @@ class ProjectsController < Projects::ApplicationController
respond_to do |format|
if result[:status] == :success
- flash[:notice] = "Project '#{@project.name}' was successfully updated."
+ flash[:notice] = _("Project '%{project_name}' was successfully updated.") % { project_name: @project.name }
format.html do
redirect_to(edit_project_path(@project))
end
@@ -76,7 +76,7 @@ class ProjectsController < Projects::ApplicationController
return access_denied! unless can?(current_user, :remove_fork_project, @project)
if ::Projects::UnlinkForkService.new(@project, current_user).execute
- flash[:notice] = 'The fork relationship has been removed.'
+ flash[:notice] = _('The fork relationship has been removed.')
end
end
@@ -97,7 +97,7 @@ class ProjectsController < Projects::ApplicationController
end
if @project.pending_delete?
- flash[:alert] = "Project #{@project.name} queued for deletion."
+ flash[:alert] = _("Project '%{project_name}' queued for deletion.") % { project_name: @project.name }
end
respond_to do |format|
@@ -117,11 +117,11 @@ class ProjectsController < Projects::ApplicationController
return access_denied! unless can?(current_user, :remove_project, @project)
::Projects::DestroyService.new(@project, current_user, {}).async_execute
- flash[:alert] = "Project '#{@project.name_with_namespace}' will be deleted."
+ flash[:alert] = _("Project '%{project_name}' will be deleted.") % { project_name: @project.name_with_namespace }
- redirect_to dashboard_projects_path
+ redirect_to dashboard_projects_path, status: 302
rescue Projects::DestroyService::DestroyError => ex
- redirect_to edit_project_path(@project), alert: ex.message
+ redirect_to edit_project_path(@project), status: 302, alert: ex.message
end
def new_issue_address
@@ -156,7 +156,7 @@ class ProjectsController < Projects::ApplicationController
redirect_to(
project_path(@project),
- notice: "Housekeeping successfully started"
+ notice: _("Housekeeping successfully started")
)
rescue ::Projects::HousekeepingService::LeaseTaken => ex
redirect_to(
@@ -170,7 +170,7 @@ class ProjectsController < Projects::ApplicationController
redirect_to(
edit_project_path(@project),
- notice: "Project export started. A download link will be sent by email."
+ notice: _("Project export started. A download link will be sent by email.")
)
end
@@ -182,16 +182,16 @@ class ProjectsController < Projects::ApplicationController
else
redirect_to(
edit_project_path(@project),
- alert: "Project export link has expired. Please generate a new export from your project settings."
+ alert: _("Project export link has expired. Please generate a new export from your project settings.")
)
end
end
def remove_export
if @project.remove_exports
- flash[:notice] = "Project export has been deleted."
+ flash[:notice] = _("Project export has been deleted.")
else
- flash[:alert] = "Project export could not be deleted."
+ flash[:alert] = _("Project export could not be deleted.")
end
redirect_to(edit_project_path(@project))
end
@@ -202,7 +202,7 @@ class ProjectsController < Projects::ApplicationController
else
redirect_to(
edit_project_path(@project),
- alert: "Project export could not be deleted."
+ alert: _("Project export could not be deleted.")
)
end
end
@@ -220,13 +220,13 @@ class ProjectsController < Projects::ApplicationController
branches = BranchesFinder.new(@repository, params).execute.map(&:name)
options = {
- 'Branches' => branches.take(100),
+ s_('RefSwitcher|Branches') => branches.take(100)
}
unless @repository.tag_count.zero?
tags = TagsFinder.new(@repository, params).execute.map(&:name)
- options['Tags'] = tags.take(100)
+ options[s_('RefSwitcher|Tags')] = tags.take(100)
end
# If reference is commit id - we should add it to branch/tag selectbox
@@ -257,7 +257,7 @@ class ProjectsController < Projects::ApplicationController
#
# pages list order: repository readme, wiki home, issues list, customize workflow
def render_landing_page
- if @project.feature_available?(:repository, current_user)
+ if can?(current_user, :download_code, @project)
return render 'projects/no_repo' unless @project.repository_exists?
render 'projects/empty' if @project.empty_repo?
else
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 3ca14dee33c..1bc6520370a 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -25,12 +25,12 @@ class RegistrationsController < Devise::RegistrationsController
end
def destroy
- DeleteUserWorker.perform_async(current_user.id, current_user.id)
+ current_user.delete_async(deleted_by: current_user)
respond_to do |format|
format.html do
session.try(:destroy)
- redirect_to new_user_session_path, notice: "Account scheduled for removal."
+ redirect_to new_user_session_path, status: 302, notice: "Account scheduled for removal."
end
end
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 8c6ba4915cd..d7c702b94f8 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -47,6 +47,10 @@ class SessionsController < Devise::SessionsController
private
+ def login_counter
+ @login_counter ||= Gitlab::Metrics.counter(:user_session_logins, 'User sign in count')
+ end
+
# Handle an "initial setup" state, where there's only one user, it's an admin,
# and they require a password change.
def check_initial_setup
@@ -90,7 +94,7 @@ class SessionsController < Devise::SessionsController
# Prevent a 'you are already signed in' message directly after signing:
# we should never redirect to '/users/sign_in' after signing in successfully.
- unless redirect_path == new_user_session_path
+ unless URI(redirect_path).path == new_user_session_path
store_location_for(:redirect, redirect_path)
end
end
@@ -103,6 +107,10 @@ class SessionsController < Devise::SessionsController
provider = Gitlab.config.omniauth.auto_sign_in_with_provider
return unless provider.present?
+ # If a "auto_sign_in" query parameter is set to a falsy value, don't auto sign-in.
+ # Otherwise, the default is to auto sign-in.
+ return if Gitlab::Utils.to_boolean(params[:auto_sign_in]) == false
+
# Auto sign in with an Omniauth provider only if the standard "you need to sign-in" alert is
# registered or no alert at all. In case of another alert (such as a blocked user), it is safer
# to do nothing to prevent redirection loops with certain Omniauth providers.
@@ -125,6 +133,7 @@ class SessionsController < Devise::SessionsController
end
def log_user_activity(user)
+ login_counter.increment
Users::ActivityService.new(user, 'login').execute
end
diff --git a/app/controllers/sherlock/transactions_controller.rb b/app/controllers/sherlock/transactions_controller.rb
index ccc739da879..cb6c3a7cd98 100644
--- a/app/controllers/sherlock/transactions_controller.rb
+++ b/app/controllers/sherlock/transactions_controller.rb
@@ -13,7 +13,7 @@ module Sherlock
def destroy_all
Gitlab::Sherlock.collection.clear
- redirect_to(:back)
+ redirect_to :back, status: 302
end
end
end
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index 7445f61195d..3d86dd2ea2c 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -45,6 +45,8 @@ class SnippetsController < ApplicationController
@snippet = CreateSnippetService.new(nil, current_user, create_params).execute
+ move_temporary_files if @snippet.valid? && params[:files]
+
recaptcha_check_with_fallback { render :new }
end
@@ -58,7 +60,7 @@ class SnippetsController < ApplicationController
def show
blob = @snippet.blob
- override_max_blob_size(blob)
+ conditionally_expand_blob(blob)
@note = Note.new(noteable: @snippet)
@noteable = @snippet
@@ -82,7 +84,7 @@ class SnippetsController < ApplicationController
@snippet.destroy
- redirect_to snippets_path
+ redirect_to snippets_path, status: 302
end
def preview_markdown
@@ -124,6 +126,12 @@ class SnippetsController < ApplicationController
end
def snippet_params
- params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level)
+ params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description)
+ end
+
+ def move_temporary_files
+ params[:files].each do |file|
+ FileMover.new(file, @snippet).execute
+ end
end
end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index eef53730291..dc882b17143 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -9,12 +9,16 @@ class UploadsController < ApplicationController
private
def find_model
+ return nil unless params[:id]
+
return render_404 unless upload_model && upload_mount
@model = upload_model.find(params[:id])
end
def authorize_access!
+ return nil unless model
+
authorized =
case model
when Note
@@ -33,6 +37,8 @@ class UploadsController < ApplicationController
end
def authorize_create_access!
+ return nil unless model
+
# for now we support only personal snippets comments
authorized = can?(current_user, :comment_personal_snippet, model)
@@ -73,7 +79,12 @@ class UploadsController < ApplicationController
def uploader
return @uploader if defined?(@uploader)
- if model.is_a?(PersonalSnippet)
+ case model
+ when nil
+ @uploader = PersonalFileUploader.new(nil, params[:secret])
+
+ @uploader.retrieve_from_store!(params[:filename])
+ when PersonalSnippet
@uploader = PersonalFileUploader.new(model, params[:secret])
@uploader.retrieve_from_store!(params[:filename])
diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb
new file mode 100644
index 00000000000..b0450ddc1fd
--- /dev/null
+++ b/app/finders/events_finder.rb
@@ -0,0 +1,62 @@
+class EventsFinder
+ attr_reader :source, :params, :current_user
+
+ # Used to filter Events
+ #
+ # Arguments:
+ # source - which user or project to looks for events on
+ # current_user - only return events for projects visible to this user
+ # params:
+ # action: string
+ # target_type: string
+ # before: datetime
+ # after: datetime
+ #
+ def initialize(params = {})
+ @source = params.delete(:source)
+ @current_user = params.delete(:current_user)
+ @params = params
+ end
+
+ def execute
+ events = source.events
+
+ events = by_current_user_access(events)
+ events = by_action(events)
+ events = by_target_type(events)
+ events = by_created_at_before(events)
+ events = by_created_at_after(events)
+
+ events
+ end
+
+ private
+
+ def by_current_user_access(events)
+ events.merge(ProjectsFinder.new(current_user: current_user).execute).references(:project)
+ end
+
+ def by_action(events)
+ return events unless Event::ACTIONS[params[:action]]
+
+ events.where(action: Event::ACTIONS[params[:action]])
+ end
+
+ def by_target_type(events)
+ return events unless Event::TARGET_TYPES[params[:target_type]]
+
+ events.where(target_type: Event::TARGET_TYPES[params[:target_type]])
+ end
+
+ def by_created_at_before(events)
+ return events unless params[:before]
+
+ events.where('events.created_at < ?', params[:before].beginning_of_day)
+ end
+
+ def by_created_at_after(events)
+ return events unless params[:after]
+
+ events.where('events.created_at > ?', params[:after].end_of_day)
+ end
+end
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index f6d8226bf3f..5bf722d1ec6 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -7,6 +7,7 @@
# project_ids_relation: int[] - project ids to use
# params:
# trending: boolean
+# owned: boolean
# non_public: boolean
# starred: boolean
# sort: string
@@ -28,13 +29,17 @@ class ProjectsFinder < UnionFinder
def execute
items = init_collection
- items = by_ids(items)
+ items = items.map do |item|
+ item = by_ids(item)
+ item = by_personal(item)
+ item = by_starred(item)
+ item = by_trending(item)
+ item = by_visibilty_level(item)
+ item = by_tags(item)
+ item = by_search(item)
+ by_archived(item)
+ end
items = union(items)
- items = by_personal(items)
- items = by_visibilty_level(items)
- items = by_tags(items)
- items = by_search(items)
- items = by_archived(items)
sort(items)
end
@@ -43,10 +48,8 @@ class ProjectsFinder < UnionFinder
def init_collection
projects = []
- if params[:trending].present?
- projects << Project.trending
- elsif params[:starred].present? && current_user
- projects << current_user.viewable_starred_projects
+ if params[:owned].present?
+ projects << current_user.owned_projects if current_user
else
projects << current_user.authorized_projects if current_user
projects << Project.unscoped.public_to_user(current_user) unless params[:non_public].present?
@@ -56,7 +59,7 @@ class ProjectsFinder < UnionFinder
end
def by_ids(items)
- project_ids_relation ? items.map { |item| item.where(id: project_ids_relation) } : items
+ project_ids_relation ? items.where(id: project_ids_relation) : items
end
def union(items)
@@ -67,6 +70,14 @@ class ProjectsFinder < UnionFinder
(params[:personal].present? && current_user) ? items.personal(current_user) : items
end
+ def by_starred(items)
+ (params[:starred].present? && current_user) ? items.starred_by(current_user) : items
+ end
+
+ def by_trending(items)
+ params[:trending].present? ? items.trending : items
+ end
+
def by_visibilty_level(items)
params[:visibility_level].present? ? items.where(visibility_level: params[:visibility_level]) : items
end
diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb
index dc13386184e..c358f23f541 100644
--- a/app/finders/todos_finder.rb
+++ b/app/finders/todos_finder.rb
@@ -39,7 +39,7 @@ class TodosFinder
private
def action_id?
- action_id.present? && Todo::ACTION_NAMES.has_key?(action_id.to_i)
+ action_id.present? && Todo::ACTION_NAMES.key?(action_id.to_i)
end
def action_id
diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb
new file mode 100644
index 00000000000..dbd50d1db7c
--- /dev/null
+++ b/app/finders/users_finder.rb
@@ -0,0 +1,74 @@
+# UsersFinder
+#
+# Used to filter users by set of params
+#
+# Arguments:
+# current_user - which user use
+# params:
+# username: string
+# extern_uid: string
+# provider: string
+# search: string
+# active: boolean
+# blocked: boolean
+# external: boolean
+#
+class UsersFinder
+ attr_accessor :current_user, :params
+
+ def initialize(current_user, params = {})
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ users = User.all
+ users = by_username(users)
+ users = by_search(users)
+ users = by_blocked(users)
+ users = by_active(users)
+ users = by_external_identity(users)
+ users = by_external(users)
+
+ users
+ end
+
+ private
+
+ def by_username(users)
+ return users unless params[:username]
+
+ users.where(username: params[:username])
+ end
+
+ def by_search(users)
+ return users unless params[:search].present?
+
+ users.search(params[:search])
+ end
+
+ def by_blocked(users)
+ return users unless params[:blocked]
+
+ users.blocked
+ end
+
+ def by_active(users)
+ return users unless params[:active]
+
+ users.active
+ end
+
+ def by_external_identity(users)
+ return users unless current_user.admin? && params[:extern_uid] && params[:provider]
+
+ users.joins(:identities).merge(Identity.with_extern_uid(params[:provider], params[:extern_uid]))
+ end
+
+ def by_external(users)
+ return users = users.where.not(external: true) unless current_user.admin?
+ return users unless params[:external]
+
+ users.external
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 8c74d36ad81..71154da7ec5 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -77,7 +77,7 @@ module ApplicationHelper
end
if user
- user.avatar_url(size) || default_avatar
+ user.avatar_url(size: size) || default_avatar
else
gravatar_icon(user_or_email, size, scale)
end
@@ -275,7 +275,25 @@ module ApplicationHelper
'active' if condition
end
- def show_user_callout?
- cookies[:user_callout_dismissed] == 'true'
+ def show_callout?(name)
+ cookies[name] != 'true'
+ end
+
+ def linkedin_url(user)
+ name = user.linkedin
+ if name =~ %r{\Ahttps?:\/\/(www\.)?linkedin\.com\/in\/}
+ name
+ else
+ "https://www.linkedin.com/in/#{name}"
+ end
+ end
+
+ def twitter_url(user)
+ name = user.twitter
+ if name =~ %r{\Ahttps?:\/\/(www\.)?twitter\.com\/}
+ name
+ else
+ "https://www.twitter.com/#{name}"
+ end
end
end
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index b7e0ff8ecd0..bbe7f3c8fb4 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -8,18 +8,28 @@ module AvatarsHelper
}))
end
- def user_avatar(options = {})
+ def user_avatar_without_link(options = {})
avatar_size = options[:size] || 16
user_name = options[:user].try(:name) || options[:user_name]
css_class = options[:css_class] || ''
-
- avatar = image_tag(
- avatar_icon(options[:user] || options[:user_email], avatar_size),
+ avatar_url = options[:url] || avatar_icon(options[:user] || options[:user_email], avatar_size)
+ data_attributes = { container: 'body' }
+
+ if options[:lazy]
+ data_attributes[:src] = avatar_url
+ end
+
+ image_tag(
+ options[:lazy] ? '' : avatar_url,
class: "avatar has-tooltip s#{avatar_size} #{css_class}",
alt: "#{user_name}'s avatar",
title: user_name,
- data: { container: 'body' }
+ data: data_attributes
)
+ end
+
+ def user_avatar(options = {})
+ avatar = user_avatar_without_link(options)
if options[:user]
link_to(avatar, user_path(options[:user]))
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index af430270ae4..3efa7c36057 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -18,7 +18,7 @@ module BlobHelper
blob = options.delete(:blob)
blob ||= project.repository.blob_at(ref, path) rescue nil
- return unless blob
+ return unless blob && blob.readable_text?
common_classes = "btn js-edit-blob #{options[:extra_class]}"
@@ -120,7 +120,7 @@ module BlobHelper
def blob_raw_url
if @build && @entry
- raw_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: @entry.path)
+ raw_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: @entry.path)
elsif @snippet
if @snippet.project_id
raw_namespace_project_snippet_path(@project.namespace, @project, @snippet)
@@ -226,7 +226,7 @@ module BlobHelper
def open_raw_blob_button(blob)
return if blob.empty?
-
+
if blob.raw_binary? || blob.stored_externally?
icon = icon('download')
title = 'Download'
@@ -240,14 +240,10 @@ module BlobHelper
def blob_render_error_reason(viewer)
case viewer.render_error
+ when :collapsed
+ "it is larger than #{number_to_human_size(viewer.collapse_limit)}"
when :too_large
- max_size =
- if viewer.absolutely_too_large?
- viewer.absolute_max_size
- elsif viewer.too_large?
- viewer.max_size
- end
- "it is larger than #{number_to_human_size(max_size)}"
+ "it is larger than #{number_to_human_size(viewer.size_limit)}"
when :server_side_but_stored_externally
case viewer.blob.external_storage
when :lfs
@@ -264,8 +260,8 @@ module BlobHelper
error = viewer.render_error
options = []
- 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)))
+ if error == :collapsed
+ options << link_to('load it anyway', url_for(params.merge(viewer: viewer.type, expanded: true, format: nil)))
end
# If the error is `:server_side_but_stored_externally`, the simple viewer will show the same error,
@@ -278,4 +274,19 @@ module BlobHelper
options
end
+
+ def contribution_options(project)
+ options = []
+
+ if can?(current_user, :create_issue, project)
+ options << link_to("submit an issue", new_namespace_project_issue_path(project.namespace, project))
+ end
+
+ merge_project = can?(current_user, :create_merge_request, project) ? project : (current_user && current_user.fork_of(project))
+ if merge_project
+ options << link_to("create a merge request", new_namespace_project_merge_request_path(project.namespace, project))
+ end
+
+ options
+ end
end
diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb
index b7a28b1b4a7..59519c1335b 100644
--- a/app/helpers/branches_helper.rb
+++ b/app/helpers/branches_helper.rb
@@ -1,14 +1,4 @@
module BranchesHelper
- def can_remove_branch?(project, branch_name)
- if ProtectedBranch.protected?(project, branch_name)
- false
- elsif branch_name == project.repository.root_ref
- false
- else
- can?(current_user, :push_code, project)
- end
- end
-
def filter_branches_path(options = {})
exist_opts = {
search: params[:search],
diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb
index 2eb2c6c7389..f0a0d245dc0 100644
--- a/app/helpers/builds_helper.rb
+++ b/app/helpers/builds_helper.rb
@@ -2,7 +2,7 @@ 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)
+ link_to "View job trace", pipeline_job_url(build.pipeline, build)
else
build.trace.html(last_lines: 10).html_safe
end
@@ -20,8 +20,8 @@ module BuildsHelper
def javascript_build_options
{
- page_url: namespace_project_build_url(@project.namespace, @project, @build),
- build_url: namespace_project_build_url(@project.namespace, @project, @build, :json),
+ page_url: namespace_project_job_url(@project.namespace, @project, @build),
+ build_url: namespace_project_job_url(@project.namespace, @project, @build, :json),
build_status: @build.status,
build_stage: @build.stage,
log_state: ''
@@ -31,7 +31,7 @@ module BuildsHelper
def build_failed_issue_options
{
title: "Build Failed ##{@build.id}",
- description: namespace_project_build_url(@project.namespace, @project, @build)
+ description: namespace_project_job_url(@project.namespace, @project, @build)
}
end
end
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index c85e96cf78d..00464810054 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -42,7 +42,10 @@ module ButtonHelper
class: "btn #{css_class}",
data: data,
type: :button,
- title: title
+ title: title,
+ aria: {
+ label: title
+ }
end
def http_clone_button(project, placement = 'right', append_link: true)
@@ -53,12 +56,12 @@ module ButtonHelper
content_tag (append_link ? :a : :span), protocol,
class: klass,
- href: (project.http_url_to_repo(current_user) if append_link),
+ href: (project.http_url_to_repo if append_link),
data: {
html: true,
placement: placement,
container: 'body',
- title: "Set a password on your account<br>to pull or push via #{protocol}"
+ title: _("Set a password on your account to pull or push via %{protocol}") % { protocol: protocol }
}
end
@@ -73,7 +76,7 @@ module ButtonHelper
html: true,
placement: placement,
container: 'body',
- title: 'Add an SSH key to your profile<br>to pull or push via SSH.'
+ title: _('Add an SSH key to your profile to pull or push via SSH.')
}
end
end
diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb
index 32b1e7822af..21c0eb8b54c 100644
--- a/app/helpers/ci_status_helper.rb
+++ b/app/helpers/ci_status_helper.rb
@@ -16,16 +16,18 @@ module CiStatusHelper
return status.label
end
- case status
- when 'success'
- 'passed'
- when 'success_with_warnings'
- 'passed with warnings'
- when 'manual'
- 'waiting for manual action'
- else
- status
- end
+ label = case status
+ when 'success'
+ 'passed'
+ when 'success_with_warnings'
+ 'passed with warnings'
+ when 'manual'
+ 'waiting for manual action'
+ else
+ status
+ end
+ translation = "CiStatusLabel|#{label}"
+ s_(translation)
end
def ci_text_for_status(status)
@@ -35,13 +37,22 @@ module CiStatusHelper
case status
when 'success'
- 'passed'
+ s_('CiStatusText|passed')
when 'success_with_warnings'
- 'passed'
+ s_('CiStatusText|passed')
when 'manual'
- 'blocked'
+ s_('CiStatusText|blocked')
else
- status
+ # All states are already being translated inside the detailed statuses:
+ # :running => Gitlab::Ci::Status::Running
+ # :skipped => Gitlab::Ci::Status::Skipped
+ # :failed => Gitlab::Ci::Status::Failed
+ # :success => Gitlab::Ci::Status::Success
+ # :canceled => Gitlab::Ci::Status::Canceled
+ # The following states are customized above:
+ # :manual => Gitlab::Ci::Status::Manual
+ status_translation = "CiStatusText|#{status}"
+ s_(status_translation)
end
end
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index cef624430da..5b5cdebe919 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -15,16 +15,6 @@ module CommitsHelper
commit_person_link(commit, options.merge(source: :committer))
end
- def image_diff_class(diff)
- if diff.deleted_file
- "deleted"
- elsif diff.new_file
- "added"
- else
- nil
- end
- end
-
def commit_to_html(commit, ref, project)
render 'projects/commits/commit',
commit: commit,
@@ -74,12 +64,8 @@ module CommitsHelper
# Returns the sorted alphabetically links to branches, separated by a comma
def commit_branches_links(project, branches)
branches.sort.map do |branch|
- link_to(
- namespace_project_tree_path(project.namespace, project, branch)
- ) do
- content_tag :span, class: 'label label-gray' do
- icon('code-fork') + ' ' + branch
- end
+ link_to(project_ref_path(project, branch), class: "label label-gray ref-name") do
+ icon('code-fork') + " #{branch}"
end
end.join(" ").html_safe
end
@@ -88,29 +74,22 @@ module CommitsHelper
def commit_tags_links(project, tags)
sorted = VersionSorter.rsort(tags)
sorted.map do |tag|
- link_to(
- namespace_project_commits_path(project.namespace, project,
- project.repository.find_tag(tag).name)
- ) do
- content_tag :span, class: 'label label-gray' do
- icon('tag') + ' ' + tag
- end
+ link_to(project_ref_path(project, tag), class: "label label-gray ref-name") do
+ icon('tag') + " #{tag}"
end
end.join(" ").html_safe
end
def link_to_browse_code(project, commit)
+ return unless current_controller?(:commits)
+
if @path.blank?
return link_to(
"Browse Files",
namespace_project_tree_path(project.namespace, project, commit),
class: "btn btn-default"
)
- end
-
- return unless current_controller?(:projects, :commits)
-
- if @repo.blob_at(commit.id, @path)
+ elsif @repo.blob_at(commit.id, @path)
return link_to(
"Browse File",
namespace_project_blob_path(project.namespace, project,
@@ -200,8 +179,8 @@ module CommitsHelper
tree_join(commit_sha, diff_new_path)),
class: 'btn view-file js-view-file'
) do
- raw('View file @') + content_tag(:span, commit_sha[0..6],
- class: 'commit-short-id')
+ raw('View file @ ') + content_tag(:span, Commit.truncate_sha(commit_sha),
+ class: 'commit-sha')
end
end
diff --git a/app/helpers/conversational_development_index_helper.rb b/app/helpers/conversational_development_index_helper.rb
new file mode 100644
index 00000000000..1ff54415811
--- /dev/null
+++ b/app/helpers/conversational_development_index_helper.rb
@@ -0,0 +1,16 @@
+module ConversationalDevelopmentIndexHelper
+ def score_level(score)
+ if score < 33.33
+ 'low'
+ elsif score < 66.66
+ 'average'
+ else
+ 'high'
+ end
+ end
+
+ def format_score(score)
+ precision = score < 1 ? 2 : 1
+ number_with_precision(score, precision: precision)
+ end
+end
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index dc144906548..2ae3a616933 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -8,8 +8,8 @@ module DiffHelper
[marked_old_line, marked_new_line]
end
- def expand_all_diffs?
- params[:expand_all_diffs].present?
+ def diffs_expanded?
+ params[:expanded].present?
end
def diff_view
@@ -22,10 +22,10 @@ module DiffHelper
end
def diff_options
- options = { ignore_whitespace_change: hide_whitespace?, no_collapse: expand_all_diffs? }
+ options = { ignore_whitespace_change: hide_whitespace?, expanded: diffs_expanded? }
if action_name == 'diff_for_path'
- options[:no_collapse] = true
+ options[:expanded] = true
options[:paths] = params.values_at(:old_path, :new_path)
end
@@ -63,15 +63,15 @@ module DiffHelper
def parallel_diff_discussions(left, right, diff_file)
return unless @grouped_diff_discussions
-
+
discussions_left = discussions_right = nil
- if left && (left.unchanged? || left.removed?)
+ if left && (left.unchanged? || left.discussable?)
line_code = diff_file.line_code(left)
discussions_left = @grouped_diff_discussions[line_code]
end
- if right && right.added?
+ if right&.discussable?
line_code = diff_file.line_code(right)
discussions_right = @grouped_diff_discussions[line_code]
end
@@ -98,18 +98,18 @@ module DiffHelper
[
content_tag(:span, link_to(truncate(blob.name, length: 40), tree)),
'@',
- content_tag(:span, commit_id, class: 'monospace'),
+ content_tag(:span, commit_id, class: 'commit-sha')
].join(' ').html_safe
end
- def commit_for_diff(diff_file)
- return diff_file.content_commit if diff_file.content_commit
+ def diff_file_blob_raw_path(diff_file)
+ namespace_project_raw_path(@project.namespace, @project, tree_join(diff_file.content_sha, diff_file.file_path))
+ end
- if diff_file.deleted_file
- @base_commit || @commit.parent || @commit
- else
- @commit
- end
+ def diff_file_old_blob_raw_path(diff_file)
+ sha = diff_file.old_content_sha
+ return unless sha
+ namespace_project_raw_path(@project.namespace, @project, tree_join(diff_file.old_content_sha, diff_file.old_path))
end
def diff_file_html_data(project, diff_file_path, diff_commit_id)
@@ -120,8 +120,8 @@ module DiffHelper
}
end
- def editable_diff?(diff)
- !diff.deleted_file && @merge_request && @merge_request.source_project
+ def editable_diff?(diff_file)
+ !diff_file.deleted_file? && @merge_request && @merge_request.source_project
end
private
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index 8ed99642c7a..ac8c518ac84 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -1,27 +1,27 @@
module DropdownsHelper
def dropdown_tag(toggle_text, options: {}, &block)
- content_tag :div, class: "dropdown #{options[:wrapper_class] if options.has_key?(:wrapper_class)}" do
+ content_tag :div, class: "dropdown #{options[:wrapper_class] if options.key?(:wrapper_class)}" do
data_attr = { toggle: "dropdown" }
- if options.has_key?(:data)
+ if options.key?(:data)
data_attr = options[:data].merge(data_attr)
end
dropdown_output = dropdown_toggle(toggle_text, data_attr, options)
- dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.has_key?(:dropdown_class)}") do
+ dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}") do
output = ""
- if options.has_key?(:title)
+ if options.key?(:title)
output << dropdown_title(options[:title])
end
- if options.has_key?(:filter)
+ if options.key?(:filter)
output << dropdown_filter(options[:placeholder])
end
- output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.has_key?(:content_class)}") do
- capture(&block) if block && !options.has_key?(:footer_content)
+ output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.key?(:content_class)}") do
+ capture(&block) if block && !options.key?(:footer_content)
end
if block && options[:footer_content]
@@ -41,7 +41,7 @@ module DropdownsHelper
def dropdown_toggle(toggle_text, data_attr, options = {})
default_label = data_attr[:default_label]
- content_tag(:button, class: "dropdown-menu-toggle #{options[:toggle_class] if options.has_key?(:toggle_class)}", id: (options[:id] if options.has_key?(:id)), type: "button", data: data_attr) do
+ content_tag(:button, class: "dropdown-menu-toggle #{options[:toggle_class] if options.key?(:toggle_class)}", id: (options[:id] if options.key?(:id)), type: "button", data: data_attr) do
output = content_tag(:span, toggle_text, class: "dropdown-toggle-text #{'is-default' if toggle_text == default_label}")
output << icon('chevron-down')
output.html_safe
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index f927cfc998f..3b24f183785 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -12,7 +12,7 @@ module EmailsHelper
"action" => {
"@type" => "ViewAction",
"name" => name,
- "url" => url,
+ "url" => url
}
}
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index 960111ca045..751d61955b7 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -41,7 +41,7 @@ module EventsHelper
link_opts = {
class: "event-filter-link",
id: "#{key}_event_filter",
- title: "Filter by #{tooltip.downcase}",
+ title: "Filter by #{tooltip.downcase}"
}
content_tag :li, class: active do
@@ -164,9 +164,14 @@ module EventsHelper
def event_note_title_html(event)
if event.note_target
- link_to(event_note_target_path(event), title: event.target_title, class: 'has-tooltip') do
- "#{event.note_target_type} #{event.note_target_reference}"
- end
+ text = raw("#{event.note_target_type} ") +
+ if event.commit_note?
+ content_tag(:span, event.note_target_reference, class: 'commit-sha')
+ else
+ event.note_target_reference
+ end
+
+ link_to(text, event_note_target_path(event), title: event.target_title, class: 'has-tooltip')
else
content_tag(:strong, '(deleted)')
end
diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb
index 7bd212a3ef9..b981a1e8242 100644
--- a/app/helpers/explore_helper.rb
+++ b/app/helpers/explore_helper.rb
@@ -10,7 +10,7 @@ module ExploreHelper
personal: params[:personal],
archived: params[:archived],
shared: params[:shared],
- namespace_id: params[:namespace_id],
+ namespace_id: params[:namespace_id]
}
options = exist_opts.merge(options).delete_if { |key, value| value.blank? }
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index bcf71bc347b..8c7af62e199 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -50,8 +50,12 @@ module GitlabRoutingHelper
namespace_project_cycle_analytics_path(project.namespace, project, *args)
end
- def project_builds_path(project, *args)
- namespace_project_builds_path(project.namespace, project, *args)
+ def project_jobs_path(project, *args)
+ namespace_project_jobs_path(project.namespace, project, *args)
+ end
+
+ def project_ref_path(project, ref_name, *args)
+ namespace_project_commits_path(project.namespace, project, ref_name, *args)
end
def project_container_registry_path(project, *args)
@@ -106,8 +110,8 @@ module GitlabRoutingHelper
namespace_project_pipeline_url(pipeline.project.namespace, pipeline.project, pipeline.id, *args)
end
- def pipeline_build_url(pipeline, build, *args)
- namespace_project_build_url(pipeline.project.namespace, pipeline.project, build.id, *args)
+ def pipeline_job_url(pipeline, build, *args)
+ namespace_project_job_url(pipeline.project.namespace, pipeline.project, build.id, *args)
end
def commits_url(entity, *args)
@@ -124,7 +128,7 @@ module GitlabRoutingHelper
def preview_markdown_path(project, *args)
if @snippet.is_a?(PersonalSnippet)
- preview_markdown_snippet_path(@snippet)
+ preview_markdown_snippets_path
else
preview_markdown_namespace_project_path(project.namespace, project, *args)
end
@@ -211,13 +215,13 @@ module GitlabRoutingHelper
case action
when 'download'
- download_namespace_project_build_artifacts_path(*args)
+ download_namespace_project_job_artifacts_path(*args)
when 'browse'
- browse_namespace_project_build_artifacts_path(*args)
+ browse_namespace_project_job_artifacts_path(*args)
when 'file'
- file_namespace_project_build_artifacts_path(*args)
+ file_namespace_project_job_artifacts_path(*args)
when 'raw'
- raw_namespace_project_build_artifacts_path(*args)
+ raw_namespace_project_job_artifacts_path(*args)
end
end
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index 55fa81e95ef..f29faeca22d 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -7,9 +7,10 @@ module IconsHelper
# font-awesome-rails gem, but should we ever use a different icon pack in the
# future we won't have to change hundreds of method calls.
def icon(names, options = {})
- if (options.keys & %w[aria-hidden aria-label]).empty?
- # Add `aria-hidden` if there are no aria's set
+ if (options.keys & %w[aria-hidden aria-label data-hidden]).empty?
+ # Add 'aria-hidden' and 'data-hidden' if they are not set in options.
options['aria-hidden'] = true
+ options['data-hidden'] = true
end
options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options)
@@ -19,6 +20,8 @@ module IconsHelper
case names
when "standard"
names = "key"
+ when "two-factor"
+ names = "key"
end
options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options)
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index f7d0ebcb16f..5e8f0849969 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -137,11 +137,9 @@ module IssuablesHelper
author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "hidden-sm hidden-md hidden-lg")
end
- if issuable.tasks?
- output << "&ensp;".html_safe
- output << content_tag(:span, issuable.task_status, id: "task_status", class: "hidden-xs hidden-sm")
- output << content_tag(:span, issuable.task_status_short, id: "task_status_short", class: "hidden-md hidden-lg")
- end
+ output << "&ensp;".html_safe
+ output << content_tag(:span, issuable.task_status, id: "task_status", class: "hidden-xs hidden-sm")
+ output << content_tag(:span, issuable.task_status_short, id: "task_status_short", class: "hidden-md hidden-lg")
output
end
@@ -201,6 +199,43 @@ module IssuablesHelper
issuable_filter_params.any? { |k| params.key?(k) }
end
+ def issuable_initial_data(issuable)
+ data = {
+ endpoint: namespace_project_issue_path(@project.namespace, @project, issuable),
+ canUpdate: can?(current_user, :update_issue, issuable),
+ canDestroy: can?(current_user, :destroy_issue, issuable),
+ canMove: current_user ? issuable.can_move?(current_user) : false,
+ issuableRef: issuable.to_reference,
+ isConfidential: issuable.confidential,
+ markdownPreviewUrl: preview_markdown_path(@project),
+ markdownDocs: help_page_path('user/markdown'),
+ projectsAutocompleteUrl: autocomplete_projects_path(project_id: @project.id),
+ issuableTemplates: issuable_templates(issuable),
+ projectPath: ref_project.path,
+ projectNamespace: ref_project.namespace.full_path,
+ initialTitleHtml: markdown_field(issuable, :title),
+ initialTitleText: issuable.title,
+ initialDescriptionHtml: markdown_field(issuable, :description),
+ initialDescriptionText: issuable.description
+ }
+
+ data.merge!(updated_at_by(issuable))
+
+ data.to_json
+ end
+
+ def updated_at_by(issuable)
+ return {} unless issuable.is_edited?
+
+ {
+ updatedAt: issuable.updated_at.to_time.iso8601,
+ updatedBy: {
+ name: issuable.last_edited_by.name,
+ path: user_path(issuable.last_edited_by)
+ }
+ }
+ end
+
private
def sidebar_gutter_collapsed?
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index e5b1e6e8bc7..4e6e6805920 100644
--- a/app/helpers/labels_helper.rb
+++ b/app/helpers/labels_helper.rb
@@ -69,13 +69,12 @@ module LabelsHelper
end
def render_colored_label(label, label_suffix = '', tooltip: true)
- label_color = label.color || Label::DEFAULT_COLOR
- text_color = text_color_for_bg(label_color)
+ text_color = text_color_for_bg(label.color)
# Intentionally not using content_tag here so that this method can be called
# by LabelReferenceFilter
span = %(<span class="label color-label #{"has-tooltip" if tooltip}" ) +
- %(style="background-color: #{label_color}; color: #{text_color}" ) +
+ %(style="background-color: #{label.color}; color: #{text_color}" ) +
%(title="#{escape_once(label.description)}" data-container="body">) +
%(#{escape_once(label.name)}#{label_suffix}</span>)
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index 4f55c12466a..941cfce8370 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -35,7 +35,7 @@ module MarkupHelper
context = {
project: @project,
current_user: (current_user if defined?(current_user)),
- pipeline: :single_line,
+ pipeline: :single_line
}
gfm_body = Banzai.render(body, context)
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 23e55539f0a..39d30631646 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -54,7 +54,7 @@ module MergeRequestsHelper
source_project_id: merge_request.source_project_id,
target_project_id: merge_request.target_project_id,
source_branch: merge_request.source_branch,
- target_branch: merge_request.target_branch,
+ target_branch: merge_request.target_branch
},
change_branches: true
)
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index 52403640c05..c59d8dafc83 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -19,7 +19,7 @@ module NotesHelper
id: noteable.id,
class: noteable.class.name,
resources: noteable.class.table_name,
- project_id: noteable.project.id,
+ project_id: noteable.project.id
}.to_json
end
@@ -34,7 +34,7 @@ module NotesHelper
data = {
line_code: line_code,
- line_type: line_type,
+ line_type: line_type
}
if @use_legacy_diff_notes
@@ -50,7 +50,7 @@ module NotesHelper
def link_to_reply_discussion(discussion, line_type = nil)
return unless current_user
- data = { discussion_id: discussion.id, line_type: line_type }
+ data = { discussion_id: discussion.reply_id, line_type: line_type }
button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button',
data: data, title: 'Add a reply'
@@ -90,14 +90,18 @@ module NotesHelper
end
end
- def note_url(note)
+ def note_url(note, project = @project)
if note.noteable.is_a?(PersonalSnippet)
snippet_note_path(note.noteable, note)
else
- namespace_project_note_path(@project.namespace, @project, note)
+ namespace_project_note_path(project.namespace, project, note)
end
end
+ def noteable_note_url(note)
+ Gitlab::UrlBuilder.build(note)
+ end
+
def form_resources
if @snippet.is_a?(PersonalSnippet)
[@note]
diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb
index 03cc8f2b6bd..fde961e2da4 100644
--- a/app/helpers/notifications_helper.rb
+++ b/app/helpers/notifications_helper.rb
@@ -21,30 +21,36 @@ module NotificationsHelper
end
def notification_title(level)
+ # Can be anything in `NotificationSetting.level:
case level.to_sym
when :participating
- 'Participate'
+ s_('NotificationLevel|Participate')
when :mention
- 'On mention'
+ s_('NotificationLevel|On mention')
else
- level.to_s.titlecase
+ N_('NotificationLevel|Global')
+ N_('NotificationLevel|Watch')
+ N_('NotificationLevel|Disabled')
+ N_('NotificationLevel|Custom')
+ level = "NotificationLevel|#{level.to_s.humanize}"
+ s_(level)
end
end
def notification_description(level)
case level.to_sym
when :participating
- 'You will only receive notifications for threads you have participated in'
+ _('You will only receive notifications for threads you have participated in')
when :mention
- 'You will receive notifications only for comments in which you were @mentioned'
+ _('You will receive notifications only for comments in which you were @mentioned')
when :watch
- 'You will receive notifications for any activity'
+ _('You will receive notifications for any activity')
when :disabled
- 'You will not get any notifications via email'
+ _('You will not get any notifications via email')
when :global
- 'Use your global notification setting'
+ _('Use your global notification setting')
when :custom
- 'You will only receive notifications for the events you choose'
+ _('You will only receive notifications for the events you choose')
end
end
@@ -76,11 +82,22 @@ module NotificationsHelper
end
def notification_event_name(event)
+ # All values from NotificationSetting::EMAIL_EVENTS
case event
when :success_pipeline
- 'Successful pipeline'
+ s_('NotificationEvent|Successful pipeline')
else
- event.to_s.humanize
+ N_('NotificationEvent|New note')
+ N_('NotificationEvent|New issue')
+ N_('NotificationEvent|Reopen issue')
+ N_('NotificationEvent|Close issue')
+ N_('NotificationEvent|Reassign issue')
+ N_('NotificationEvent|New merge request')
+ N_('NotificationEvent|Close merge request')
+ N_('NotificationEvent|Reassign merge request')
+ N_('NotificationEvent|Merge merge request')
+ N_('NotificationEvent|Failed pipeline')
+ s_(event.to_s.humanize)
end
end
end
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index de959f13713..d36bb4ab074 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -49,7 +49,7 @@ module PreferencesHelper
user_view = current_user.project_view
- if @project.feature_available?(:repository, current_user)
+ if can?(current_user, :download_code, @project)
user_view
elsif user_view == "activity"
"activity"
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
new file mode 100644
index 00000000000..45238f12ac7
--- /dev/null
+++ b/app/helpers/profiles_helper.rb
@@ -0,0 +1,7 @@
+module ProfilesHelper
+ def email_provider_label
+ return unless current_user.external_email?
+
+ current_user.email_provider.present? ? Gitlab::OAuth::Provider.label_for(current_user.email_provider) : "LDAP"
+ end
+end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 8c26348a975..7441b58fddb 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -70,21 +70,30 @@ module ProjectsHelper
end
def remove_project_message(project)
- "You are going to remove #{project.name_with_namespace}.\n Removed project CANNOT be restored!\n Are you ABSOLUTELY sure?"
+ _("You are going to remove %{project_name_with_namespace}.\nRemoved project CANNOT be restored!\nAre you ABSOLUTELY sure?") %
+ { project_name_with_namespace: project.name_with_namespace }
end
def transfer_project_message(project)
- "You are going to transfer #{project.name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
+ _("You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?") %
+ { project_name_with_namespace: project.name_with_namespace }
end
def remove_fork_project_message(project)
- "You are going to remove the fork relationship to source project #{@project.forked_from_project.name_with_namespace}. Are you ABSOLUTELY sure?"
+ _("You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?") %
+ { forked_from_project: @project.forked_from_project.name_with_namespace }
end
def project_nav_tabs
@nav_tabs ||= get_project_nav_tabs(@project, current_user)
end
+ def project_search_tabs?(tab)
+ abilities = Array(search_tab_ability_map[tab])
+
+ abilities.any? { |ability| can?(current_user, ability, @project) }
+ end
+
def project_nav_tab?(name)
project_nav_tabs.include? name
end
@@ -110,15 +119,13 @@ module ProjectsHelper
end
def license_short_name(project)
- return 'LICENSE' if project.repository.license_key.nil?
-
- license = Licensee::License.new(project.repository.license_key)
-
- license.nickname || license.name
+ license = project.repository.license
+ license&.nickname || license&.name || 'LICENSE'
end
def last_push_event
return unless current_user
+ return current_user.recent_push unless @project
project_ids = [@project.id]
if fork = current_user.fork_of(@project)
@@ -134,11 +141,15 @@ module ProjectsHelper
if @project.private?
level = @project.project_feature.send(field)
- options.delete('Everyone with access')
- highest_available_option = options.values.max if level == ProjectFeature::ENABLED
+ disabled_option = ProjectFeature::ENABLED
+ highest_available_option = ProjectFeature::PRIVATE if level == disabled_option
end
- options = options_for_select(options, selected: highest_available_option || @project.project_feature.public_send(field))
+ options = options_for_select(
+ options.invert,
+ selected: highest_available_option || @project.project_feature.public_send(field),
+ disabled: disabled_option
+ )
content_tag(
:select,
@@ -151,16 +162,25 @@ module ProjectsHelper
end
def link_to_autodeploy_doc
- link_to 'About auto deploy', help_page_path('ci/autodeploy/index'), target: '_blank'
+ link_to _('About auto deploy'), help_page_path('ci/autodeploy/index'), target: '_blank'
end
def autodeploy_flash_notice(branch_name)
- "Branch <strong>#{truncate(sanitize(branch_name))}</strong> was created. To set up auto deploy, \
- choose a GitLab CI Yaml template and commit your changes. #{link_to_autodeploy_doc}".html_safe
+ translation = _("Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}") %
+ { branch_name: truncate(sanitize(branch_name)), link_to_autodeploy_doc: link_to_autodeploy_doc }
+ translation.html_safe
end
def project_list_cache_key(project)
- key = [project.namespace.cache_key, project.cache_key, controller.controller_name, controller.action_name, current_application_settings.cache_key, 'v2.4']
+ key = [
+ project.route.cache_key,
+ project.cache_key,
+ controller.controller_name,
+ controller.action_name,
+ current_application_settings.cache_key,
+ 'v2.4'
+ ]
+
key << pipeline_status_cache_key(project.pipeline_status) if project.pipeline_status.has_status?
key
@@ -198,7 +218,17 @@ module ProjectsHelper
nav_tabs << :container_registry
end
- tab_ability_map = {
+ tab_ability_map.each do |tab, ability|
+ if can?(current_user, ability, project)
+ nav_tabs << tab
+ end
+ end
+
+ nav_tabs.flatten
+ end
+
+ def tab_ability_map
+ {
environments: :read_environment,
milestones: :read_milestone,
pipelines: :read_pipeline,
@@ -210,24 +240,25 @@ module ProjectsHelper
team: :read_project_member,
wiki: :read_wiki
}
+ end
- tab_ability_map.each do |tab, ability|
- if can?(current_user, ability, project)
- nav_tabs << tab
- end
- end
-
- nav_tabs.flatten
+ def search_tab_ability_map
+ @search_tab_ability_map ||= tab_ability_map.merge(
+ blobs: :download_code,
+ commits: :download_code,
+ merge_requests: :read_merge_request,
+ notes: [:read_merge_request, :download_code, :read_issue, :read_project_snippet]
+ )
end
def project_lfs_status(project)
if project.lfs_enabled?
content_tag(:span, class: 'lfs-enabled') do
- 'Enabled'
+ s_('LFSStatus|Enabled')
end
else
content_tag(:span, class: 'lfs-disabled') do
- 'Disabled'
+ s_('LFSStatus|Disabled')
end
end
end
@@ -236,7 +267,7 @@ module ProjectsHelper
if current_user
current_user.name
else
- "Your name"
+ _("Your name")
end
end
@@ -253,7 +284,7 @@ module ProjectsHelper
when 'ssh'
project.ssh_url_to_repo
else
- project.http_url_to_repo(current_user)
+ project.http_url_to_repo
end
end
@@ -273,17 +304,18 @@ module ProjectsHelper
if project.last_activity_at
time_ago_with_tooltip(project.last_activity_at, placement: 'bottom', html_class: 'last_activity_time_ago')
else
- "Never"
+ s_("ProjectLastActivity|Never")
end
end
def add_special_file_path(project, file_name:, commit_message: nil, branch_name: nil, context: nil)
+ commit_message ||= s_("CommitMessage|Add %{file_name}") % { file_name: file_name.downcase }
namespace_project_new_blob_path(
project.namespace,
project,
project.default_branch || 'master',
file_name: file_name,
- commit_message: commit_message || "Add #{file_name.downcase}",
+ commit_message: commit_message,
branch_name: branch_name,
context: context
)
@@ -420,9 +452,9 @@ module ProjectsHelper
def project_feature_options
{
- 'Disabled' => ProjectFeature::DISABLED,
- 'Only team members' => ProjectFeature::PRIVATE,
- 'Everyone with access' => ProjectFeature::ENABLED
+ ProjectFeature::DISABLED => s_('ProjectFeature|Disabled'),
+ ProjectFeature::PRIVATE => s_('ProjectFeature|Only team members'),
+ ProjectFeature::ENABLED => s_('ProjectFeature|Everyone with access')
}
end
diff --git a/app/helpers/rss_helper.rb b/app/helpers/rss_helper.rb
index ea5d2932ef4..9ac4df88dc3 100644
--- a/app/helpers/rss_helper.rb
+++ b/app/helpers/rss_helper.rb
@@ -1,5 +1,5 @@
module RssHelper
def rss_url_options
- { format: :atom, private_token: current_user.try(:private_token) }
+ { format: :atom, rss_token: current_user.try(:rss_token) }
end
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 8ff8db16514..9c46035057f 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -42,7 +42,7 @@ module SearchHelper
{ category: "Settings", label: "User settings", url: profile_path },
{ category: "Settings", label: "SSH Keys", url: profile_keys_path },
{ category: "Settings", label: "Dashboard", url: root_path },
- { category: "Settings", label: "Admin Section", url: admin_root_path },
+ { category: "Settings", label: "Admin Section", url: admin_root_path }
]
end
@@ -57,7 +57,7 @@ module SearchHelper
{ category: "Help", label: "SSH Keys Help", url: help_page_path("ssh/README") },
{ category: "Help", label: "System Hooks Help", url: help_page_path("system_hooks/system_hooks") },
{ category: "Help", label: "Webhooks Help", url: help_page_path("user/project/integrations/webhooks") },
- { category: "Help", label: "Workflow Help", url: help_page_path("workflow/README") },
+ { category: "Help", label: "Workflow Help", url: help_page_path("workflow/README") }
]
end
@@ -76,7 +76,7 @@ module SearchHelper
{ category: "Current Project", label: "Milestones", url: namespace_project_milestones_path(@project.namespace, @project) },
{ category: "Current Project", label: "Snippets", url: namespace_project_snippets_path(@project.namespace, @project) },
{ category: "Current Project", label: "Members", url: namespace_project_settings_members_path(@project.namespace, @project) },
- { category: "Current Project", label: "Wiki", url: namespace_project_wikis_path(@project.namespace, @project) },
+ { category: "Current Project", label: "Wiki", url: namespace_project_wikis_path(@project.namespace, @project) }
]
else
[]
diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb
index 8706876ae4a..1a4f1431bdc 100644
--- a/app/helpers/selects_helper.rb
+++ b/app/helpers/selects_helper.rb
@@ -45,6 +45,14 @@ module SelectsHelper
end
end
+ with_feature_enabled_data_attribute =
+ case opts.delete(:with_feature_enabled)
+ when 'issues' then 'data-with-issues-enabled'
+ when 'merge_requests' then 'data-with-merge-requests-enabled'
+ end
+
+ opts[with_feature_enabled_data_attribute] = true
+
hidden_field_tag(id, opts[:selected], opts)
end
@@ -67,7 +75,7 @@ module SelectsHelper
current_user: opts[:current_user] || false,
"push-code-to-protected-branches" => opts[:push_code_to_protected_branches],
author_id: opts[:author_id] || '',
- skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil,
+ skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil
}
end
end
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 4882d9b71d2..b408ec0c6a4 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -58,7 +58,7 @@ module SortingHelper
sort_value_due_date_soon => sort_title_due_date_soon,
sort_value_due_date_later => sort_title_due_date_later,
sort_value_start_date_soon => sort_title_start_date_soon,
- sort_value_start_date_later => sort_title_start_date_later,
+ sort_value_start_date_later => sort_title_start_date_later
}
end
diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb
index b739554a7a4..8e0a1e2ecdf 100644
--- a/app/helpers/submodule_helper.rb
+++ b/app/helpers/submodule_helper.rb
@@ -7,8 +7,24 @@ module SubmoduleHelper
def submodule_links(submodule_item, ref = nil, repository = @repository)
url = repository.submodule_url_for(ref, submodule_item.path)
+ if url == '.' || url == './'
+ url = File.join(Gitlab.config.gitlab.url, @project.full_path)
+ end
+
if url =~ /([^\/:]+)\/([^\/]+(?:\.git)?)\Z/
namespace, project = $1, $2
+ gitlab_hosts = [Gitlab.config.gitlab.url,
+ Gitlab.config.gitlab_shell.ssh_path_prefix]
+
+ gitlab_hosts.each do |host|
+ if url.start_with?(host)
+ namespace, _, project = url.sub(host, '').rpartition('/')
+ break
+ end
+ end
+
+ namespace.sub!(/\A\//, '')
+ project.rstrip!
project.sub!(/\.git\z/, '')
if self_url?(url, namespace, project)
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index d889d141101..209bd56b78a 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -17,7 +17,8 @@ module SystemNoteHelper
'visible' => 'icon_eye',
'milestone' => 'icon_clock_o',
'discussion' => 'icon_comment_o',
- 'moved' => 'icon_arrow_circle_o_right'
+ 'moved' => 'icon_arrow_circle_o_right',
+ 'outdated' => 'icon_edit'
}.freeze
def icon_for_system_note(note)
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index f19e2f9db9c..19286fadb19 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -24,10 +24,13 @@ module TodosHelper
end
def todo_target_link(todo)
- target = todo.target_type.titleize.downcase
- link_to "#{target} #{todo.target_reference}", todo_target_path(todo),
- class: 'has-tooltip',
- title: todo.target.title
+ text = raw("#{todo.target_type.titleize.downcase} ") +
+ if todo.for_commit?
+ content_tag(:span, todo.target_reference, class: 'commit-sha')
+ else
+ todo.target_reference
+ end
+ link_to text, todo_target_path(todo), class: 'has-tooltip', title: todo.target.title
end
def todo_target_path(todo)
@@ -63,7 +66,7 @@ module TodosHelper
project_id: params[:project_id],
author_id: params[:author_id],
type: params[:type],
- action_id: params[:action_id],
+ action_id: params[:action_id]
}
end
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index b4aaf498068..35755bc149b 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -29,11 +29,11 @@ module VisibilityLevelHelper
def project_visibility_level_description(level)
case level
when Gitlab::VisibilityLevel::PRIVATE
- "Project access must be granted explicitly to each user."
+ _("Project access must be granted explicitly to each user.")
when Gitlab::VisibilityLevel::INTERNAL
- "The project can be cloned by any logged in user."
+ _("The project can be accessed by any logged in user.")
when Gitlab::VisibilityLevel::PUBLIC
- "The project can be cloned without any authentication."
+ _("The project can be accessed without any authentication.")
end
end
@@ -81,7 +81,9 @@ module VisibilityLevelHelper
end
def visibility_level_label(level)
- Project.visibility_levels.key(level)
+ # The visibility level can be:
+ # 'VisibilityLevel|Private', 'VisibilityLevel|Internal', 'VisibilityLevel|Public'
+ s_(Project.visibility_levels.key(level))
end
def restricted_visibility_levels(show_all = false)
diff --git a/app/mailers/base_mailer.rb b/app/mailers/base_mailer.rb
index d2980db218a..654468bc7fe 100644
--- a/app/mailers/base_mailer.rb
+++ b/app/mailers/base_mailer.rb
@@ -1,4 +1,6 @@
class BaseMailer < ActionMailer::Base
+ around_action :render_with_default_locale
+
helper ApplicationHelper
helper MarkupHelper
@@ -14,6 +16,10 @@ class BaseMailer < ActionMailer::Base
private
+ def render_with_default_locale(&block)
+ Gitlab::I18n.with_default_locale(&block)
+ end
+
def default_sender_address
address = Mail::Address.new(Gitlab.config.gitlab.email_from)
address.display_name = Gitlab.config.gitlab.email_display_name
diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb
index 0d7c2d20029..4cbd90c5817 100644
--- a/app/models/abuse_report.rb
+++ b/app/models/abuse_report.rb
@@ -15,8 +15,7 @@ class AbuseReport < ActiveRecord::Base
alias_method :author, :reporter
def remove_user(deleted_by:)
- user.block
- DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true, hard_delete: true)
+ user.delete_async(deleted_by: deleted_by, params: { hard_delete: true })
end
def notify
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 043f57241a3..2192f76499d 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -13,13 +13,13 @@ class ApplicationSetting < ActiveRecord::Base
[\r\n] # any number of newline characters
}x
- serialize :restricted_visibility_levels
- serialize :import_sources
- serialize :disabled_oauth_sign_in_sources, Array
- serialize :domain_whitelist, Array
- serialize :domain_blacklist, Array
- serialize :repository_storages
- serialize :sidekiq_throttling_queues, Array
+ serialize :restricted_visibility_levels # rubocop:disable Cop/ActiverecordSerialize
+ serialize :import_sources # rubocop:disable Cop/ActiverecordSerialize
+ serialize :disabled_oauth_sign_in_sources, Array # rubocop:disable Cop/ActiverecordSerialize
+ serialize :domain_whitelist, Array # rubocop:disable Cop/ActiverecordSerialize
+ serialize :domain_blacklist, Array # rubocop:disable Cop/ActiverecordSerialize
+ serialize :repository_storages # rubocop:disable Cop/ActiverecordSerialize
+ serialize :sidekiq_throttling_queues, Array # rubocop:disable Cop/ActiverecordSerialize
cache_markdown_field :sign_in_text
cache_markdown_field :help_page_text
@@ -143,7 +143,7 @@ class ApplicationSetting < ActiveRecord::Base
validates_each :restricted_visibility_levels do |record, attr, value|
value&.each do |level|
- unless Gitlab::VisibilityLevel.options.has_value?(level)
+ unless Gitlab::VisibilityLevel.options.value?(level)
record.errors.add(attr, "'#{level}' is not a valid visibility level")
end
end
@@ -151,7 +151,7 @@ class ApplicationSetting < ActiveRecord::Base
validates_each :import_sources do |record, attr, value|
value&.each do |source|
- unless Gitlab::ImportSources.options.has_value?(source)
+ unless Gitlab::ImportSources.options.value?(source)
record.errors.add(attr, "'#{source}' is not a import source")
end
end
@@ -189,8 +189,9 @@ class ApplicationSetting < ActiveRecord::Base
end
def self.cached
- ensure_cache_setup
- Rails.cache.fetch(CACHE_KEY)
+ value = Rails.cache.read(CACHE_KEY)
+ ensure_cache_setup if value.present?
+ value
end
def self.ensure_cache_setup
@@ -199,7 +200,7 @@ class ApplicationSetting < ActiveRecord::Base
ApplicationSetting.define_attribute_methods
end
- def self.defaults_ce
+ def self.defaults
{
after_sign_up_text: nil,
akismet_enabled: false,
@@ -250,10 +251,6 @@ class ApplicationSetting < ActiveRecord::Base
}
end
- def self.defaults
- defaults_ce
- end
-
def self.create_from_defaults
create(defaults)
end
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index 967ffd46db0..46d412fbd72 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -1,5 +1,5 @@
class AuditEvent < ActiveRecord::Base
- serialize :details, Hash
+ serialize :details, Hash # rubocop:disable Cop/ActiverecordSerialize
belongs_to :user, foreign_key: :author_id
diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb
index 6ada6fae4eb..ebe60441603 100644
--- a/app/models/award_emoji.rb
+++ b/app/models/award_emoji.rb
@@ -5,7 +5,7 @@ class AwardEmoji < ActiveRecord::Base
include Participable
include GhostUser
- belongs_to :awardable, polymorphic: true
+ belongs_to :awardable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :user
validates :awardable, :user, presence: true
diff --git a/app/models/blob.rb b/app/models/blob.rb
index eaf0b713122..6a42a12891c 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -33,12 +33,31 @@ class Blob < SimpleDelegator
BlobViewer::PDF,
BlobViewer::BinarySTL,
- BlobViewer::TextSTL,
+ BlobViewer::TextSTL
+ ].sort_by { |v| v.binary? ? 0 : 1 }.freeze
+
+ AUXILIARY_VIEWERS = [
+ BlobViewer::GitlabCiYml,
+ BlobViewer::RouteMap,
+
+ BlobViewer::Readme,
+ BlobViewer::License,
+ BlobViewer::Contributing,
+ BlobViewer::Changelog,
+
+ BlobViewer::Cartfile,
+ BlobViewer::ComposerJson,
+ BlobViewer::Gemfile,
+ BlobViewer::Gemspec,
+ BlobViewer::GodepsJson,
+ BlobViewer::PackageJson,
+ BlobViewer::Podfile,
+ BlobViewer::Podspec,
+ BlobViewer::PodspecJson,
+ BlobViewer::RequirementsTxt,
+ BlobViewer::YarnLock
].freeze
- BINARY_VIEWERS = RICH_VIEWERS.select(&:binary?).freeze
- TEXT_VIEWERS = RICH_VIEWERS.select(&:text?).freeze
-
attr_reader :project
# Wrap a Gitlab::Git::Blob object, or return nil when given nil
@@ -83,10 +102,6 @@ class Blob < SimpleDelegator
raw_size == 0
end
- def too_large?
- size && truncated?
- end
-
def external_storage_error?
if external_storage == :lfs
!project&.lfs_enabled?
@@ -141,7 +156,7 @@ class Blob < SimpleDelegator
end
def readable_text?
- text? && !stored_externally? && !too_large?
+ text? && !stored_externally? && !truncated?
end
def simple_viewer
@@ -154,6 +169,12 @@ class Blob < SimpleDelegator
@rich_viewer = rich_viewer_class&.new(self)
end
+ def auxiliary_viewer
+ return @auxiliary_viewer if defined?(@auxiliary_viewer)
+
+ @auxiliary_viewer = auxiliary_viewer_class&.new(self)
+ end
+
def rendered_as_text?(ignore_errors: true)
simple_viewer.text? && (ignore_errors || simple_viewer.render_error.nil?)
end
@@ -162,9 +183,9 @@ class Blob < SimpleDelegator
rendered_as_text? && rich_viewer
end
- def override_max_size!
- simple_viewer&.override_max_size = true
- rich_viewer&.override_max_size = true
+ def expand!
+ simple_viewer&.expanded = true
+ rich_viewer&.expanded = true
end
private
@@ -180,17 +201,18 @@ class Blob < SimpleDelegator
end
def rich_viewer_class
+ viewer_class_from(RICH_VIEWERS)
+ end
+
+ def auxiliary_viewer_class
+ viewer_class_from(AUXILIARY_VIEWERS)
+ end
+
+ def viewer_class_from(classes)
return if empty? || external_storage_error?
- classes =
- if stored_externally?
- BINARY_VIEWERS + TEXT_VIEWERS
- elsif binary?
- BINARY_VIEWERS
- else # text
- TEXT_VIEWERS
- end
+ verify_binary = !stored_externally?
- classes.find { |viewer_class| viewer_class.can_render?(self) }
+ classes.find { |viewer_class| viewer_class.can_render?(self, verify_binary: verify_binary) }
end
end
diff --git a/app/models/blob_viewer/auxiliary.rb b/app/models/blob_viewer/auxiliary.rb
new file mode 100644
index 00000000000..1bea225f17c
--- /dev/null
+++ b/app/models/blob_viewer/auxiliary.rb
@@ -0,0 +1,18 @@
+module BlobViewer
+ module Auxiliary
+ extend ActiveSupport::Concern
+
+ include Gitlab::Allowable
+
+ included do
+ self.loading_partial_name = 'loading_auxiliary'
+ self.type = :auxiliary
+ self.collapse_limit = 100.kilobytes
+ self.size_limit = 100.kilobytes
+ end
+
+ def visible_to?(current_user)
+ true
+ end
+ end
+end
diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb
index a8b91d8d6bc..e6119d25fab 100644
--- a/app/models/blob_viewer/base.rb
+++ b/app/models/blob_viewer/base.rb
@@ -1,18 +1,28 @@
module BlobViewer
class Base
- class_attribute :partial_name, :type, :extensions, :client_side, :binary, :switcher_icon, :switcher_title, :max_size, :absolute_max_size
+ PARTIAL_PATH_PREFIX = 'projects/blob/viewers'.freeze
- delegate :partial_path, :rich?, :simple?, :client_side?, :server_side?, :text?, :binary?, to: :class
+ class_attribute :partial_name, :loading_partial_name, :type, :extensions, :file_types, :load_async, :binary, :switcher_icon, :switcher_title, :collapse_limit, :size_limit
+
+ self.loading_partial_name = 'loading'
+
+ delegate :partial_path, :loading_partial_path, :rich?, :simple?, :text?, :binary?, to: :class
attr_reader :blob
- attr_accessor :override_max_size
+ attr_accessor :expanded
+
+ delegate :project, to: :blob
def initialize(blob)
@blob = blob
end
def self.partial_path
- "projects/blob/viewers/#{partial_name}"
+ File.join(PARTIAL_PATH_PREFIX, partial_name)
+ end
+
+ def self.loading_partial_path
+ File.join(PARTIAL_PATH_PREFIX, loading_partial_name)
end
def self.rich?
@@ -23,12 +33,12 @@ module BlobViewer
type == :simple
end
- def self.client_side?
- client_side
+ def self.auxiliary?
+ type == :auxiliary
end
- def self.server_side?
- !client_side?
+ def self.load_async?
+ load_async
end
def self.binary?
@@ -39,20 +49,28 @@ module BlobViewer
!binary?
end
- def self.can_render?(blob)
- !extensions || extensions.include?(blob.extension)
+ def self.can_render?(blob, verify_binary: true)
+ return false if verify_binary && binary? != blob.binary?
+ return true if extensions&.include?(blob.extension)
+ return true if file_types&.include?(Gitlab::FileDetector.type_of(blob.path))
+
+ false
end
- def too_large?
- blob.raw_size > max_size
+ def load_async?
+ self.class.load_async? && render_error.nil?
end
- def absolutely_too_large?
- blob.raw_size > absolute_max_size
+ def collapsed?
+ return @collapsed if defined?(@collapsed)
+
+ @collapsed = !expanded && collapse_limit && blob.raw_size > collapse_limit
end
- def can_override_max_size?
- too_large? && !absolutely_too_large?
+ def too_large?
+ return @too_large if defined?(@too_large)
+
+ @too_large = size_limit && blob.raw_size > size_limit
end
# This method is used on the server side to check whether we can attempt to
@@ -67,31 +85,15 @@ module BlobViewer
# binary from `blob_raw_url` and does its own format validation and error
# rendering, especially for potentially large binary formats.
def render_error
- return @render_error if defined?(@render_error)
-
- @render_error =
- 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_externally
- elsif override_max_size ? absolutely_too_large? : too_large?
- :too_large
- end
- end
-
- def prepare!
- if server_side? && blob.project
- blob.load_all_data!(blob.project.repository)
+ if too_large?
+ :too_large
+ elsif collapsed?
+ :collapsed
end
end
- private
-
- def server_side_but_stored_externally?
- server_side? && blob.stored_externally?
+ def prepare!
+ # To be overridden by subclasses
end
end
end
diff --git a/app/models/blob_viewer/cartfile.rb b/app/models/blob_viewer/cartfile.rb
new file mode 100644
index 00000000000..d8471bc33c0
--- /dev/null
+++ b/app/models/blob_viewer/cartfile.rb
@@ -0,0 +1,15 @@
+module BlobViewer
+ class Cartfile < DependencyManager
+ include Static
+
+ self.file_types = %i(cartfile)
+
+ def manager_name
+ 'Carthage'
+ end
+
+ def manager_url
+ 'https://github.com/Carthage/Carthage'
+ end
+ end
+end
diff --git a/app/models/blob_viewer/changelog.rb b/app/models/blob_viewer/changelog.rb
new file mode 100644
index 00000000000..0464ae27f71
--- /dev/null
+++ b/app/models/blob_viewer/changelog.rb
@@ -0,0 +1,16 @@
+module BlobViewer
+ class Changelog < Base
+ include Auxiliary
+ include Static
+
+ self.partial_name = 'changelog'
+ self.file_types = %i(changelog)
+ self.binary = false
+
+ def render_error
+ return if project.repository.tag_count > 0
+
+ :no_tags
+ end
+ end
+end
diff --git a/app/models/blob_viewer/client_side.rb b/app/models/blob_viewer/client_side.rb
index 42ec68f864b..079cfbe3616 100644
--- a/app/models/blob_viewer/client_side.rb
+++ b/app/models/blob_viewer/client_side.rb
@@ -3,9 +3,9 @@ module BlobViewer
extend ActiveSupport::Concern
included do
- self.client_side = true
- self.max_size = 10.megabytes
- self.absolute_max_size = 50.megabytes
+ self.load_async = false
+ self.collapse_limit = 10.megabytes
+ self.size_limit = 50.megabytes
end
end
end
diff --git a/app/models/blob_viewer/composer_json.rb b/app/models/blob_viewer/composer_json.rb
new file mode 100644
index 00000000000..ef8b4aef8e8
--- /dev/null
+++ b/app/models/blob_viewer/composer_json.rb
@@ -0,0 +1,23 @@
+module BlobViewer
+ class ComposerJson < DependencyManager
+ include ServerSide
+
+ self.file_types = %i(composer_json)
+
+ def manager_name
+ 'Composer'
+ end
+
+ def manager_url
+ 'https://getcomposer.com/'
+ end
+
+ def package_name
+ @package_name ||= package_name_from_json('name')
+ end
+
+ def package_url
+ "https://packagist.org/packages/#{package_name}"
+ end
+ end
+end
diff --git a/app/models/blob_viewer/contributing.rb b/app/models/blob_viewer/contributing.rb
new file mode 100644
index 00000000000..fbd1dd48697
--- /dev/null
+++ b/app/models/blob_viewer/contributing.rb
@@ -0,0 +1,10 @@
+module BlobViewer
+ class Contributing < Base
+ include Auxiliary
+ include Static
+
+ self.partial_name = 'contributing'
+ self.file_types = %i(contributing)
+ self.binary = false
+ end
+end
diff --git a/app/models/blob_viewer/dependency_manager.rb b/app/models/blob_viewer/dependency_manager.rb
new file mode 100644
index 00000000000..a8d9be945dc
--- /dev/null
+++ b/app/models/blob_viewer/dependency_manager.rb
@@ -0,0 +1,43 @@
+module BlobViewer
+ class DependencyManager < Base
+ include Auxiliary
+
+ self.partial_name = 'dependency_manager'
+ self.binary = false
+
+ def manager_name
+ raise NotImplementedError
+ end
+
+ def manager_url
+ raise NotImplementedError
+ end
+
+ def package_type
+ 'package'
+ end
+
+ def package_name
+ nil
+ end
+
+ def package_url
+ nil
+ end
+
+ private
+
+ def package_name_from_json(key)
+ prepare!
+
+ JSON.parse(blob.data)[key] rescue nil
+ end
+
+ def package_name_from_method_call(name)
+ prepare!
+
+ match = blob.data.match(/#{name}\s*=\s*["'](?<name>[^"']+)["']/)
+ match[:name] if match
+ end
+ end
+end
diff --git a/app/models/blob_viewer/download.rb b/app/models/blob_viewer/download.rb
index adc06587f69..074e7204814 100644
--- a/app/models/blob_viewer/download.rb
+++ b/app/models/blob_viewer/download.rb
@@ -1,17 +1,9 @@
module BlobViewer
class Download < Base
include Simple
- # We treat the Download viewer as if it renders the content client-side,
- # so that it doesn't attempt to load the entire blob contents and is
- # rendered synchronously instead of loaded asynchronously.
- include ClientSide
+ include Static
self.partial_name = 'download'
self.binary = true
-
- # We can always render the Download viewer, even if the blob is in LFS or too large.
- def render_error
- nil
- end
end
end
diff --git a/app/models/blob_viewer/gemfile.rb b/app/models/blob_viewer/gemfile.rb
new file mode 100644
index 00000000000..fae8c8df23f
--- /dev/null
+++ b/app/models/blob_viewer/gemfile.rb
@@ -0,0 +1,15 @@
+module BlobViewer
+ class Gemfile < DependencyManager
+ include Static
+
+ self.file_types = %i(gemfile gemfile_lock)
+
+ def manager_name
+ 'Bundler'
+ end
+
+ def manager_url
+ 'http://bundler.io/'
+ end
+ end
+end
diff --git a/app/models/blob_viewer/gemspec.rb b/app/models/blob_viewer/gemspec.rb
new file mode 100644
index 00000000000..7802edeb754
--- /dev/null
+++ b/app/models/blob_viewer/gemspec.rb
@@ -0,0 +1,27 @@
+module BlobViewer
+ class Gemspec < DependencyManager
+ include ServerSide
+
+ self.file_types = %i(gemspec)
+
+ def manager_name
+ 'RubyGems'
+ end
+
+ def manager_url
+ 'https://rubygems.org/'
+ end
+
+ def package_type
+ 'gem'
+ end
+
+ def package_name
+ @package_name ||= package_name_from_method_call('name')
+ end
+
+ def package_url
+ "https://rubygems.org/gems/#{package_name}"
+ end
+ end
+end
diff --git a/app/models/blob_viewer/gitlab_ci_yml.rb b/app/models/blob_viewer/gitlab_ci_yml.rb
new file mode 100644
index 00000000000..7267c3965d3
--- /dev/null
+++ b/app/models/blob_viewer/gitlab_ci_yml.rb
@@ -0,0 +1,23 @@
+module BlobViewer
+ class GitlabCiYml < Base
+ include ServerSide
+ include Auxiliary
+
+ self.partial_name = 'gitlab_ci_yml'
+ self.loading_partial_name = 'gitlab_ci_yml_loading'
+ self.file_types = %i(gitlab_ci)
+ self.binary = false
+
+ def validation_message
+ return @validation_message if defined?(@validation_message)
+
+ prepare!
+
+ @validation_message = Ci::GitlabCiYamlProcessor.validation_message(blob.data)
+ end
+
+ def valid?
+ validation_message.blank?
+ end
+ end
+end
diff --git a/app/models/blob_viewer/godeps_json.rb b/app/models/blob_viewer/godeps_json.rb
new file mode 100644
index 00000000000..e19a602603b
--- /dev/null
+++ b/app/models/blob_viewer/godeps_json.rb
@@ -0,0 +1,15 @@
+module BlobViewer
+ class GodepsJson < DependencyManager
+ include Static
+
+ self.file_types = %i(godeps_json)
+
+ def manager_name
+ 'godep'
+ end
+
+ def manager_url
+ 'https://github.com/tools/godep'
+ end
+ end
+end
diff --git a/app/models/blob_viewer/license.rb b/app/models/blob_viewer/license.rb
new file mode 100644
index 00000000000..57355f2c3aa
--- /dev/null
+++ b/app/models/blob_viewer/license.rb
@@ -0,0 +1,20 @@
+module BlobViewer
+ class License < Base
+ include Auxiliary
+ include Static
+
+ self.partial_name = 'license'
+ self.file_types = %i(license)
+ self.binary = false
+
+ def license
+ project.repository.license
+ end
+
+ def render_error
+ return if license
+
+ :unknown_license
+ end
+ end
+end
diff --git a/app/models/blob_viewer/markup.rb b/app/models/blob_viewer/markup.rb
index 8fdbab30dd1..33b59c4f512 100644
--- a/app/models/blob_viewer/markup.rb
+++ b/app/models/blob_viewer/markup.rb
@@ -5,6 +5,7 @@ module BlobViewer
self.partial_name = 'markup'
self.extensions = Gitlab::MarkupHelper::EXTENSIONS
+ self.file_types = %i(readme)
self.binary = false
end
end
diff --git a/app/models/blob_viewer/package_json.rb b/app/models/blob_viewer/package_json.rb
new file mode 100644
index 00000000000..09221efb56c
--- /dev/null
+++ b/app/models/blob_viewer/package_json.rb
@@ -0,0 +1,23 @@
+module BlobViewer
+ class PackageJson < DependencyManager
+ include ServerSide
+
+ self.file_types = %i(package_json)
+
+ def manager_name
+ 'npm'
+ end
+
+ def manager_url
+ 'https://www.npmjs.com/'
+ end
+
+ def package_name
+ @package_name ||= package_name_from_json('name')
+ end
+
+ def package_url
+ "https://www.npmjs.com/package/#{package_name}"
+ end
+ end
+end
diff --git a/app/models/blob_viewer/podfile.rb b/app/models/blob_viewer/podfile.rb
new file mode 100644
index 00000000000..507bc734cb4
--- /dev/null
+++ b/app/models/blob_viewer/podfile.rb
@@ -0,0 +1,15 @@
+module BlobViewer
+ class Podfile < DependencyManager
+ include Static
+
+ self.file_types = %i(podfile)
+
+ def manager_name
+ 'CocoaPods'
+ end
+
+ def manager_url
+ 'https://cocoapods.org/'
+ end
+ end
+end
diff --git a/app/models/blob_viewer/podspec.rb b/app/models/blob_viewer/podspec.rb
new file mode 100644
index 00000000000..a4c242db3a9
--- /dev/null
+++ b/app/models/blob_viewer/podspec.rb
@@ -0,0 +1,27 @@
+module BlobViewer
+ class Podspec < DependencyManager
+ include ServerSide
+
+ self.file_types = %i(podspec)
+
+ def manager_name
+ 'CocoaPods'
+ end
+
+ def manager_url
+ 'https://cocoapods.org/'
+ end
+
+ def package_type
+ 'pod'
+ end
+
+ def package_name
+ @package_name ||= package_name_from_method_call('name')
+ end
+
+ def package_url
+ "https://cocoapods.org/pods/#{package_name}"
+ end
+ end
+end
diff --git a/app/models/blob_viewer/podspec_json.rb b/app/models/blob_viewer/podspec_json.rb
new file mode 100644
index 00000000000..602f4a51fd9
--- /dev/null
+++ b/app/models/blob_viewer/podspec_json.rb
@@ -0,0 +1,9 @@
+module BlobViewer
+ class PodspecJson < Podspec
+ self.file_types = %i(podspec_json)
+
+ def package_name
+ @package_name ||= package_name_from_json('name')
+ end
+ end
+end
diff --git a/app/models/blob_viewer/readme.rb b/app/models/blob_viewer/readme.rb
new file mode 100644
index 00000000000..75c373a03bb
--- /dev/null
+++ b/app/models/blob_viewer/readme.rb
@@ -0,0 +1,14 @@
+module BlobViewer
+ class Readme < Base
+ include Auxiliary
+ include Static
+
+ self.partial_name = 'readme'
+ self.file_types = %i(readme)
+ self.binary = false
+
+ def visible_to?(current_user)
+ can?(current_user, :read_wiki, project)
+ end
+ end
+end
diff --git a/app/models/blob_viewer/requirements_txt.rb b/app/models/blob_viewer/requirements_txt.rb
new file mode 100644
index 00000000000..83ac55f61d0
--- /dev/null
+++ b/app/models/blob_viewer/requirements_txt.rb
@@ -0,0 +1,15 @@
+module BlobViewer
+ class RequirementsTxt < DependencyManager
+ include Static
+
+ self.file_types = %i(requirements_txt)
+
+ def manager_name
+ 'pip'
+ end
+
+ def manager_url
+ 'https://pip.pypa.io/'
+ end
+ end
+end
diff --git a/app/models/blob_viewer/route_map.rb b/app/models/blob_viewer/route_map.rb
new file mode 100644
index 00000000000..153b4eeb2c9
--- /dev/null
+++ b/app/models/blob_viewer/route_map.rb
@@ -0,0 +1,30 @@
+module BlobViewer
+ class RouteMap < Base
+ include ServerSide
+ include Auxiliary
+
+ self.partial_name = 'route_map'
+ self.loading_partial_name = 'route_map_loading'
+ self.file_types = %i(route_map)
+ self.binary = false
+
+ def validation_message
+ return @validation_message if defined?(@validation_message)
+
+ prepare!
+
+ @validation_message =
+ begin
+ Gitlab::RouteMap.new(blob.data)
+
+ nil
+ rescue Gitlab::RouteMap::FormatError => e
+ e.message
+ end
+ end
+
+ def valid?
+ validation_message.blank?
+ end
+ end
+end
diff --git a/app/models/blob_viewer/server_side.rb b/app/models/blob_viewer/server_side.rb
index 899107d02ea..05a3dd7d913 100644
--- a/app/models/blob_viewer/server_side.rb
+++ b/app/models/blob_viewer/server_side.rb
@@ -3,9 +3,28 @@ module BlobViewer
extend ActiveSupport::Concern
included do
- self.client_side = false
- self.max_size = 2.megabytes
- self.absolute_max_size = 5.megabytes
+ self.load_async = true
+ self.collapse_limit = 2.megabytes
+ self.size_limit = 5.megabytes
+ end
+
+ def prepare!
+ if blob.project
+ blob.load_all_data!(blob.project.repository)
+ end
+ end
+
+ def render_error
+ if blob.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.
+ return :server_side_but_stored_externally
+ end
+
+ super
end
end
end
diff --git a/app/models/blob_viewer/static.rb b/app/models/blob_viewer/static.rb
new file mode 100644
index 00000000000..c9e257e5388
--- /dev/null
+++ b/app/models/blob_viewer/static.rb
@@ -0,0 +1,14 @@
+module BlobViewer
+ module Static
+ extend ActiveSupport::Concern
+
+ included do
+ self.load_async = false
+ end
+
+ # We can always render a static viewer, even if the blob is too large.
+ def render_error
+ nil
+ end
+ end
+end
diff --git a/app/models/blob_viewer/text.rb b/app/models/blob_viewer/text.rb
index e27b2c2b493..f68cbb7e212 100644
--- a/app/models/blob_viewer/text.rb
+++ b/app/models/blob_viewer/text.rb
@@ -5,7 +5,7 @@ module BlobViewer
self.partial_name = 'text'
self.binary = false
- self.max_size = 1.megabyte
- self.absolute_max_size = 10.megabytes
+ self.collapse_limit = 1.megabyte
+ self.size_limit = 10.megabytes
end
end
diff --git a/app/models/blob_viewer/yarn_lock.rb b/app/models/blob_viewer/yarn_lock.rb
new file mode 100644
index 00000000000..31588ddcbab
--- /dev/null
+++ b/app/models/blob_viewer/yarn_lock.rb
@@ -0,0 +1,15 @@
+module BlobViewer
+ class YarnLock < DependencyManager
+ include Static
+
+ self.file_types = %i(yarn_lock)
+
+ def manager_name
+ 'Yarn'
+ end
+
+ def manager_url
+ 'https://yarnpkg.com/'
+ end
+ end
+end
diff --git a/app/models/board.rb b/app/models/board.rb
index cf8317891b5..18081a32157 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -5,6 +5,10 @@ class Board < ActiveRecord::Base
validates :project, presence: true
+ def backlog_list
+ lists.merge(List.backlog).take
+ end
+
def closed_list
lists.merge(List.closed).take
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 3c4a4d93349..cec1ca89a6a 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -19,8 +19,8 @@ module Ci
)
end
- serialize :options
- serialize :yaml_variables, Gitlab::Serializer::Ci::Variables
+ serialize :options # rubocop:disable Cop/ActiverecordSerialize
+ serialize :yaml_variables, Gitlab::Serializer::Ci::Variables # rubocop:disable Cop/ActiverecordSerialize
delegate :name, to: :project, prefix: true
@@ -47,10 +47,16 @@ module Ci
before_destroy { unscoped_project }
after_create :execute_hooks
- after_save :update_project_statistics, if: :artifacts_size_changed?
- after_destroy :update_project_statistics
+ after_commit :update_project_statistics_after_save, on: [:create, :update]
+ after_commit :update_project_statistics, on: :destroy
class << self
+ # This is needed for url_for to work,
+ # as the controller is JobsController
+ def model_name
+ ActiveModel::Name.new(self, nil, 'job')
+ end
+
def first_pending
pending.unstarted.order('created_at ASC').first
end
@@ -132,6 +138,17 @@ module Ci
ExpandVariables.expand(environment, simple_variables) if environment
end
+ def environment_url
+ return @environment_url if defined?(@environment_url)
+
+ @environment_url =
+ if unexpanded_url = options&.dig(:environment, :url)
+ ExpandVariables.expand(unexpanded_url, simple_variables)
+ else
+ persisted_environment&.external_url
+ end
+ end
+
def has_environment?
environment.present?
end
@@ -185,27 +202,30 @@ module Ci
variables += project.deployment_variables if has_environment?
variables += yaml_variables
variables += user_variables
- variables += project.secret_variables
+ variables += project.secret_variables_for(ref).map(&:to_runner_variable)
variables += trigger_request.user_variables if trigger_request
variables
end
# All variables, including those dependent on other variables
def variables
- variables = simple_variables
- variables += persisted_environment.predefined_variables if persisted_environment.present?
- variables
+ simple_variables.concat(persisted_environment_variables)
end
def merge_request
- merge_requests = MergeRequest.includes(:merge_request_diff)
- .where(source_branch: ref,
- source_project: pipeline.project)
- .reorder(iid: :asc)
-
- merge_requests.find do |merge_request|
- merge_request.commits_sha.include?(pipeline.sha)
- end
+ return @merge_request if defined?(@merge_request)
+
+ @merge_request ||=
+ begin
+ merge_requests = MergeRequest.includes(:merge_request_diff)
+ .where(source_branch: ref,
+ source_project: pipeline.project)
+ .reorder(iid: :desc)
+
+ merge_requests.find do |merge_request|
+ merge_request.commits_sha.include?(pipeline.sha)
+ end
+ end
end
def repo_url
@@ -249,38 +269,6 @@ module Ci
Time.now - updated_at > 15.minutes.to_i
end
- ##
- # Deprecated
- #
- # This contains a hotfix for CI build data integrity, see #4246
- #
- # This method is used by `ArtifactUploader` to create a store_dir.
- # Warning: Uploader uses it after AND before file has been stored.
- #
- # This method returns old path to artifacts only if it already exists.
- #
- def artifacts_path
- # We need the project even if it's soft deleted, because whenever
- # we're really deleting the project, we'll also delete the builds,
- # and in order to delete the builds, we need to know where to find
- # the artifacts, which is depending on the data of the project.
- # We need to retain the project in this case.
- the_project = project || unscoped_project
-
- old = File.join(created_at.utc.strftime('%Y_%m'),
- the_project.ci_id.to_s,
- id.to_s)
-
- old_store = File.join(ArtifactUploader.artifacts_path, old)
- return old if the_project.ci_id && File.directory?(old_store)
-
- File.join(
- created_at.utc.strftime('%Y_%m'),
- the_project.id.to_s,
- id.to_s
- )
- end
-
def valid_token?(token)
self.token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token)
end
@@ -300,8 +288,8 @@ module Ci
def execute_hooks
return unless project
build_data = Gitlab::DataBuilder::Build.build(self)
- project.execute_hooks(build_data.dup, :build_hooks)
- project.execute_services(build_data.dup, :build_hooks)
+ project.execute_hooks(build_data.dup, :job_hooks)
+ project.execute_services(build_data.dup, :job_hooks)
PagesService.new(build_data).execute
project.running_or_pending_build_count(force: true)
end
@@ -361,7 +349,7 @@ module Ci
end
def has_expiring_artifacts?
- artifacts_expire_at.present?
+ artifacts_expire_at.present? && artifacts_expire_at > Time.now
end
def keep_artifacts!
@@ -488,6 +476,18 @@ module Ci
variables.concat(legacy_variables)
end
+ def persisted_environment_variables
+ return [] unless persisted_environment
+
+ variables = persisted_environment.predefined_variables
+
+ if url = environment_url
+ variables << { key: 'CI_ENVIRONMENT_URL', value: url, public: true }
+ end
+
+ variables
+ end
+
def legacy_variables
variables = [
{ key: 'CI_BUILD_ID', value: id.to_s, public: true },
@@ -517,5 +517,11 @@ module Ci
ProjectCacheWorker.perform_async(project_id, [], [:build_artifacts_size])
end
+
+ def update_project_statistics_after_save
+ if previous_changes.include?('artifacts_size')
+ update_project_statistics
+ end
+ end
end
end
diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb
new file mode 100644
index 00000000000..9b536af672b
--- /dev/null
+++ b/app/models/ci/legacy_stage.rb
@@ -0,0 +1,64 @@
+module Ci
+ # Currently this is artificial object, constructed dynamically
+ # We should migrate this object to actual database record in the future
+ class LegacyStage
+ include StaticModel
+
+ attr_reader :pipeline, :name
+
+ delegate :project, to: :pipeline
+
+ def initialize(pipeline, name:, status: nil, warnings: nil)
+ @pipeline = pipeline
+ @name = name
+ @status = status
+ @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
+
+ def statuses_count
+ @statuses_count ||= statuses.count
+ end
+
+ def status
+ @status ||= statuses.latest.status
+ end
+
+ def detailed_status(current_user)
+ Gitlab::Ci::Status::Stage::Factory
+ .new(self, current_user)
+ .fabricate!
+ end
+
+ def statuses
+ @statuses ||= pipeline.statuses.where(stage: name)
+ end
+
+ def builds
+ @builds ||= pipeline.builds.where(stage: name)
+ end
+
+ def success?
+ status.to_s == 'success'
+ end
+
+ def has_warnings?
+ if @warnings.is_a?(Integer)
+ @warnings > 0
+ else
+ statuses.latest.failed_but_allowed.any?
+ end
+ end
+ end
+end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index db994b861e5..9ddecba5183 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -11,21 +11,27 @@ module Ci
belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
belongs_to :pipeline_schedule, class_name: 'Ci::PipelineSchedule'
- has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id'
- has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id'
-
+ has_many :stages
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id
has_many :builds, foreign_key: :commit_id
has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id
+ # Merge requests for which the current pipeline is running against
+ # the merge request's latest commit.
+ has_many :merge_requests, foreign_key: "head_pipeline_id"
+
has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build'
has_many :retryable_builds, -> { latest.failed_or_canceled }, foreign_key: :commit_id, class_name: 'Ci::Build'
has_many :cancelable_statuses, -> { cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus'
- has_many :manual_actions, -> { latest.manual_actions }, foreign_key: :commit_id, class_name: 'Ci::Build'
- has_many :artifacts, -> { latest.with_artifacts_not_expired }, foreign_key: :commit_id, class_name: 'Ci::Build'
+ has_many :manual_actions, -> { latest.manual_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build'
+ has_many :artifacts, -> { latest.with_artifacts_not_expired.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build'
+
+ has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id'
+ has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id'
delegate :id, to: :project, prefix: true
+ validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create
validates :sha, presence: { unless: :importing? }
validates :ref, presence: { unless: :importing? }
validates :status, presence: { unless: :importing? }
@@ -33,6 +39,16 @@ module Ci
after_create :keep_around_commits, unless: :importing?
+ enum source: {
+ unknown: nil,
+ push: 1,
+ web: 2,
+ trigger: 3,
+ schedule: 4,
+ api: 5,
+ external: 6
+ }
+
state_machine :status, initial: :created do
event :enqueue do
transition created: :pending
@@ -147,21 +163,21 @@ module Ci
where.not(duration: nil).sum(:duration)
end
- def stage(name)
- stage = Ci::Stage.new(self, name: name)
- stage unless stage.statuses_count.zero?
- end
-
def stages_count
statuses.select(:stage).distinct.count
end
- def stages_name
+ def stages_names
statuses.order(:stage_idx).distinct.
pluck(:stage, :stage_idx).map(&:first)
end
- def stages
+ def legacy_stage(name)
+ stage = Ci::LegacyStage.new(self, name: name)
+ stage unless stage.statuses_count.zero?
+ end
+
+ def legacy_stages
# TODO, this needs refactoring, see gitlab-ce#26481.
stages_query = statuses
@@ -176,7 +192,7 @@ module Ci
.pluck('sg.stage', status_sql, "(#{warnings_sql})")
stages_with_statuses.map do |stage|
- Ci::Stage.new(self, Hash[%i[name status warnings].zip(stage)])
+ Ci::LegacyStage.new(self, Hash[%i[name status warnings].zip(stage)])
end
end
@@ -265,10 +281,6 @@ module Ci
commit.sha == sha
end
- def triggered?
- trigger_requests.any?
- end
-
def retried
@retried ||= (statuses.order(id: :desc) - statuses.latest)
end
@@ -280,12 +292,14 @@ module Ci
end
end
- def config_builds_attributes
+ def stage_seeds
return [] unless config_processor
- config_processor.
- builds_for_ref(ref, tag?, trigger_requests.first).
- sort_by { |build| build[:stage_idx] }
+ @stage_seeds ||= config_processor.stage_seeds(self)
+ end
+
+ def has_stage_seeds?
+ stage_seeds.any?
end
def has_warnings?
@@ -293,7 +307,7 @@ module Ci
end
def config_processor
- return nil unless ci_yaml_file
+ return unless ci_yaml_file
return @config_processor if defined?(@config_processor)
@config_processor ||= begin
@@ -381,14 +395,6 @@ module Ci
project.execute_services(data, :pipeline_hooks)
end
- # Merge requests for which the current pipeline is running against
- # the merge request's latest commit.
- def merge_requests
- @merge_requests ||= project.merge_requests
- .where(source_branch: self.ref)
- .select { |merge_request| merge_request.head_pipeline.try(:id) == self.id }
- end
-
# All the merge requests for which the current pipeline runs/ran against
def all_merge_requests
@all_merge_requests ||= project.merge_requests.where(source_branch: ref)
diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb
index 6d7cc83971e..45d8cd34359 100644
--- a/app/models/ci/pipeline_schedule.rb
+++ b/app/models/ci/pipeline_schedule.rb
@@ -10,9 +10,9 @@ module Ci
has_one :last_pipeline, -> { order(id: :desc) }, class_name: 'Ci::Pipeline'
has_many :pipelines
- validates :cron, unless: :importing_or_inactive?, cron: true, presence: { unless: :importing_or_inactive? }
- validates :cron_timezone, cron_timezone: true, presence: { unless: :importing_or_inactive? }
- validates :ref, presence: { unless: :importing_or_inactive? }
+ validates :cron, unless: :importing?, cron: true, presence: { unless: :importing? }
+ validates :cron_timezone, cron_timezone: true, presence: { unless: :importing? }
+ validates :ref, presence: { unless: :importing? }
validates :description, presence: true
before_save :set_next_run_at
@@ -24,12 +24,20 @@ module Ci
owner == current_user
end
+ def own!(user)
+ update(owner: user)
+ end
+
def inactive?
!active?
end
- def importing_or_inactive?
- importing? || inactive?
+ def deactivate!
+ update_attribute(:active, false)
+ end
+
+ def runnable_by_owner?
+ Ability.allowed?(owner, :create_pipeline, project)
end
def set_next_run_at
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index 9bda3186c30..59570924c8d 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -1,64 +1,11 @@
module Ci
- # Currently this is artificial object, constructed dynamically
- # We should migrate this object to actual database record in the future
- class Stage
- include StaticModel
+ class Stage < ActiveRecord::Base
+ extend Ci::Model
- attr_reader :pipeline, :name
+ belongs_to :project
+ belongs_to :pipeline
- delegate :project, to: :pipeline
-
- def initialize(pipeline, name:, status: nil, warnings: nil)
- @pipeline = pipeline
- @name = name
- @status = status
- @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
-
- def statuses_count
- @statuses_count ||= statuses.count
- end
-
- def status
- @status ||= statuses.latest.status
- end
-
- def detailed_status(current_user)
- Gitlab::Ci::Status::Stage::Factory
- .new(self, current_user)
- .fabricate!
- end
-
- def statuses
- @statuses ||= pipeline.statuses.where(stage: name)
- end
-
- def builds
- @builds ||= pipeline.builds.where(stage: name)
- end
-
- def success?
- status.to_s == 'success'
- end
-
- def has_warnings?
- if @warnings.is_a?(Integer)
- @warnings > 0
- else
- statuses.latest.failed_but_allowed.any?
- end
- end
+ has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id
+ has_many :builds, foreign_key: :commit_id
end
end
diff --git a/app/models/ci/trigger_request.rb b/app/models/ci/trigger_request.rb
index 2b807731d0d..564334ad1ad 100644
--- a/app/models/ci/trigger_request.rb
+++ b/app/models/ci/trigger_request.rb
@@ -6,7 +6,7 @@ module Ci
belongs_to :pipeline, foreign_key: :commit_id
has_many :builds
- serialize :variables
+ serialize :variables # rubocop:disable Cop/ActiverecordSerialize
def user_variables
return [] unless variables
diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb
index 6c6586110c5..f235260208f 100644
--- a/app/models/ci/variable.rb
+++ b/app/models/ci/variable.rb
@@ -12,11 +12,16 @@ module Ci
message: "can contain only letters, digits and '_'." }
scope :order_key_asc, -> { reorder(key: :asc) }
+ scope :unprotected, -> { where(protected: false) }
attr_encrypted :value,
mode: :per_attribute_iv_and_salt,
insecure_mode: true,
key: Gitlab::Application.secrets.db_key_base,
algorithm: 'aes-256-cbc'
+
+ def to_runner_variable
+ { key: key, value: value, public: false }
+ end
end
end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 9359b323ed4..1a766c9f6d0 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -14,7 +14,7 @@ class Commit
participant :committer
participant :notes_with_associations
- attr_accessor :project
+ attr_accessor :project, :author
DIFF_SAFE_LINES = Gitlab::Git::DiffCollection::DEFAULT_LIMITS[:max_lines]
@@ -49,7 +49,7 @@ class Commit
def max_diff_options
{
max_files: DIFF_HARD_LIMIT_FILES,
- max_lines: DIFF_HARD_LIMIT_LINES,
+ max_lines: DIFF_HARD_LIMIT_LINES
}
end
@@ -177,7 +177,7 @@ class Commit
if RequestStore.active?
key = "commit_author:#{author_email.downcase}"
# nil is a valid value since no author may exist in the system
- if RequestStore.store.has_key?(key)
+ if RequestStore.store.key?(key)
@author = RequestStore.store[key]
else
@author = find_author_by_any_email
@@ -326,16 +326,23 @@ class Commit
end
def raw_diffs(*args)
- use_gitaly = Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs)
- deltas_only = args.last.is_a?(Hash) && args.last[:deltas_only]
-
- if use_gitaly && !deltas_only
- Gitlab::GitalyClient::Commit.diff_from_parent(self, *args)
+ if Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs)
+ Gitlab::GitalyClient::Commit.new(project.repository).diff_from_parent(self, *args)
else
raw.diffs(*args)
end
end
+ def raw_deltas
+ @deltas ||= Gitlab::GitalyClient.migrate(:commit_deltas) do |is_enabled|
+ if is_enabled
+ Gitlab::GitalyClient::Commit.new(project.repository).commit_deltas(self)
+ else
+ raw.deltas
+ end
+ end
+ end
+
def diffs(diff_options = nil)
Gitlab::Diff::FileCollection::Commit.new(self, diff_options: diff_options)
end
@@ -373,7 +380,7 @@ class Commit
def repo_changes
changes = { added: [], modified: [], removed: [] }
- raw_diffs(deltas_only: true).each do |diff|
+ raw_deltas.each do |diff|
if diff.deleted_file
changes[:removed] << diff.old_path
elsif diff.renamed_file || diff.new_file
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index fe63728ea23..b9f1948c9eb 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -5,10 +5,10 @@ class CommitStatus < ActiveRecord::Base
self.table_name = 'ci_builds'
+ belongs_to :user
belongs_to :project
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id
belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
- belongs_to :user
delegate :commit, to: :pipeline
delegate :sha, :short_sha, to: :pipeline
@@ -18,7 +18,7 @@ class CommitStatus < ActiveRecord::Base
validates :name, presence: true
alias_attribute :author, :user
-
+
scope :failed_but_allowed, -> do
where(allow_failure: true, status: [:failed, :canceled])
end
@@ -83,7 +83,7 @@ class CommitStatus < ActiveRecord::Base
next if transition.loopback?
commit_status.run_after_commit do
- pipeline.try do |pipeline|
+ if pipeline
if complete? || manual?
PipelineProcessWorker.perform_async(pipeline.id)
else
@@ -91,6 +91,8 @@ class CommitStatus < ActiveRecord::Base
end
ExpireJobCacheWorker.perform_async(commit_status.id)
end
+
+ ExpireJobCacheWorker.perform_async(commit_status.id)
end
end
@@ -126,6 +128,11 @@ class CommitStatus < ActiveRecord::Base
false
end
+ # To be overriden when inherrited from
+ def retryable?
+ false
+ end
+
def stuck?
false
end
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
new file mode 100644
index 00000000000..8fbfed11bdf
--- /dev/null
+++ b/app/models/concerns/avatarable.rb
@@ -0,0 +1,18 @@
+module Avatarable
+ extend ActiveSupport::Concern
+
+ def avatar_path(only_path: true)
+ return unless self[:avatar].present?
+
+ # If only_path is true then use the relative path of avatar.
+ # Otherwise use full path (including host).
+ asset_host = ActionController::Base.asset_host
+ gitlab_host = only_path ? gitlab_config.relative_url_root : gitlab_config.url
+
+ # If asset_host is set then it is expected that assets are handled by a standalone host.
+ # That means we do not want to get GitLab's relative_url_root option anymore.
+ host = asset_host.present? ? asset_host : gitlab_host
+
+ [host, avatar.url].join
+ end
+end
diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb
index a7bdf5587b2..eee1a36ac6b 100644
--- a/app/models/concerns/discussion_on_diff.rb
+++ b/app/models/concerns/discussion_on_diff.rb
@@ -47,4 +47,12 @@ module DiscussionOnDiff
prev_lines
end
+
+ def line_code_in_diffs(diff_refs)
+ if active?(diff_refs)
+ line_code
+ elsif diff_refs && created_at_diff?(diff_refs)
+ original_line_code
+ end
+ end
end
diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb
index dff7b6e3523..3c9c6584e02 100644
--- a/app/models/concerns/has_status.rb
+++ b/app/models/concerns/has_status.rb
@@ -82,7 +82,7 @@ module HasStatus
scope :failed_or_canceled, -> { where(status: [:failed, :canceled]) }
scope :cancelable, -> do
- where(status: [:running, :pending, :created, :manual])
+ where(status: [:running, :pending, :created])
end
end
diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb
index 85505d235b7..c034bf9cbc0 100644
--- a/app/models/concerns/mentionable.rb
+++ b/app/models/concerns/mentionable.rb
@@ -79,6 +79,8 @@ module Mentionable
# Extract GFM references to other Mentionables from this Mentionable. Always excludes its #local_reference.
def referenced_mentionables(current_user = self.author)
+ return [] unless matches_cross_reference_regex?
+
refs = all_references(current_user)
refs = (refs.issues + refs.merge_requests + refs.commits)
@@ -88,6 +90,20 @@ module Mentionable
refs.reject { |ref| ref == local_reference }
end
+ # Uses regex to quickly determine if mentionables might be referenced
+ # Allows heavy processing to be skipped
+ def matches_cross_reference_regex?
+ reference_pattern = if !project || project.default_issues_tracker?
+ ReferenceRegexes::DEFAULT_PATTERN
+ else
+ ReferenceRegexes::EXTERNAL_PATTERN
+ end
+
+ self.class.mentionable_attrs.any? do |attr, _|
+ __send__(attr) =~ reference_pattern
+ end
+ end
+
# Create a cross-reference Note for each GFM reference to another Mentionable found in the +mentionable_attrs+.
def create_cross_references!(author = self.author, without = [])
refs = referenced_mentionables(author)
diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb
new file mode 100644
index 00000000000..1848230ec7e
--- /dev/null
+++ b/app/models/concerns/mentionable/reference_regexes.rb
@@ -0,0 +1,22 @@
+module Mentionable
+ module ReferenceRegexes
+ def self.reference_pattern(link_patterns, issue_pattern)
+ Regexp.union(link_patterns,
+ issue_pattern,
+ Commit.reference_pattern,
+ MergeRequest.reference_pattern)
+ end
+
+ DEFAULT_PATTERN = begin
+ issue_pattern = Issue.reference_pattern
+ link_patterns = Regexp.union([Issue, Commit, MergeRequest].map(&:link_reference_pattern))
+ reference_pattern(link_patterns, issue_pattern)
+ end
+
+ EXTERNAL_PATTERN = begin
+ issue_pattern = ExternalIssue.reference_pattern
+ link_patterns = URI.regexp(%w(http https))
+ reference_pattern(link_patterns, issue_pattern)
+ end
+ end
+end
diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb
index 6359f7596b1..f734952fa6c 100644
--- a/app/models/concerns/note_on_diff.rb
+++ b/app/models/concerns/note_on_diff.rb
@@ -33,14 +33,4 @@ module NoteOnDiff
def created_at_diff?(diff_refs)
false
end
-
- private
-
- def noteable_diff_refs
- if noteable.respond_to?(:diff_sha_refs)
- noteable.diff_sha_refs
- else
- noteable.diff_refs
- end
- end
end
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index dd1e6630642..c7bdc997eca 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -43,7 +43,12 @@ module Noteable
end
def resolvable_discussions
- @resolvable_discussions ||= discussion_notes.resolvable.discussions(self)
+ @resolvable_discussions ||=
+ if defined?(@discussions)
+ @discussions.select(&:resolvable?)
+ else
+ discussion_notes.resolvable.discussions(self)
+ end
end
def discussions_resolvable?
diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb
index c41b807df8a..a40148a4394 100644
--- a/app/models/concerns/protected_branch_access.rb
+++ b/app/models/concerns/protected_branch_access.rb
@@ -7,5 +7,27 @@ module ProtectedBranchAccess
belongs_to :protected_branch
delegate :project, to: :protected_branch
+
+ validates :access_level, presence: true, inclusion: {
+ in: [
+ Gitlab::Access::MASTER,
+ Gitlab::Access::DEVELOPER,
+ Gitlab::Access::NO_ACCESS
+ ]
+ }
+
+ def self.human_access_levels
+ {
+ Gitlab::Access::MASTER => "Masters",
+ Gitlab::Access::DEVELOPER => "Developers + Masters",
+ Gitlab::Access::NO_ACCESS => "No one"
+ }.with_indifferent_access
+ end
+
+ def check_access(user)
+ return false if access_level == Gitlab::Access::NO_ACCESS
+
+ super
+ end
end
end
diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb
index 62eaec2407f..47e71c58557 100644
--- a/app/models/concerns/protected_ref.rb
+++ b/app/models/concerns/protected_ref.rb
@@ -8,32 +8,44 @@ module ProtectedRef
validates :project, presence: true
delegate :matching, :matches?, :wildcard?, to: :ref_matcher
+ end
+
+ def commit
+ project.commit(self.name)
+ end
+
+ class_methods do
+ def protected_ref_access_levels(*types)
+ types.each do |type|
+ has_many :"#{type}_access_levels", dependent: :destroy
+
+ validates :"#{type}_access_levels", length: { is: 1, message: "are restricted to a single instance per #{self.model_name.human}." }
- def self.protected_ref_accessible_to?(ref, user, action:)
+ accepts_nested_attributes_for :"#{type}_access_levels", allow_destroy: true
+ end
+ end
+
+ def protected_ref_accessible_to?(ref, user, action:)
access_levels_for_ref(ref, action: action).any? do |access_level|
access_level.check_access(user)
end
end
- def self.developers_can?(action, ref)
+ def developers_can?(action, ref)
access_levels_for_ref(ref, action: action).any? do |access_level|
access_level.access_level == Gitlab::Access::DEVELOPER
end
end
- def self.access_levels_for_ref(ref, action:)
+ def access_levels_for_ref(ref, action:)
self.matching(ref).map(&:"#{action}_access_levels").flatten
end
- def self.matching(ref_name, protected_refs: nil)
+ def matching(ref_name, protected_refs: nil)
ProtectedRefMatcher.matching(self, ref_name, protected_refs: protected_refs)
end
end
- def commit
- project.commit(self.name)
- end
-
private
def ref_matcher
diff --git a/app/models/conversational_development_index/card.rb b/app/models/conversational_development_index/card.rb
new file mode 100644
index 00000000000..e8f09dc9161
--- /dev/null
+++ b/app/models/conversational_development_index/card.rb
@@ -0,0 +1,26 @@
+module ConversationalDevelopmentIndex
+ class Card
+ attr_accessor :metric, :title, :description, :feature, :blog, :docs
+
+ def initialize(metric:, title:, description:, feature:, blog:, docs: nil)
+ self.metric = metric
+ self.title = title
+ self.description = description
+ self.feature = feature
+ self.blog = blog
+ self.docs = docs
+ end
+
+ def instance_score
+ metric.instance_score(feature)
+ end
+
+ def leader_score
+ metric.leader_score(feature)
+ end
+
+ def percentage_score
+ metric.percentage_score(feature)
+ end
+ end
+end
diff --git a/app/models/conversational_development_index/idea_to_production_step.rb b/app/models/conversational_development_index/idea_to_production_step.rb
new file mode 100644
index 00000000000..6e1753c9f30
--- /dev/null
+++ b/app/models/conversational_development_index/idea_to_production_step.rb
@@ -0,0 +1,19 @@
+module ConversationalDevelopmentIndex
+ class IdeaToProductionStep
+ attr_accessor :metric, :title, :features
+
+ def initialize(metric:, title:, features:)
+ self.metric = metric
+ self.title = title
+ self.features = features
+ end
+
+ def percentage_score
+ sum = features.sum do |feature|
+ metric.percentage_score(feature)
+ end
+
+ sum / features.size.to_f
+ end
+ end
+end
diff --git a/app/models/conversational_development_index/metric.rb b/app/models/conversational_development_index/metric.rb
new file mode 100644
index 00000000000..f42f516f99a
--- /dev/null
+++ b/app/models/conversational_development_index/metric.rb
@@ -0,0 +1,21 @@
+module ConversationalDevelopmentIndex
+ class Metric < ActiveRecord::Base
+ include Presentable
+
+ self.table_name = 'conversational_development_index_metrics'
+
+ def instance_score(feature)
+ self["instance_#{feature}"]
+ end
+
+ def leader_score(feature)
+ self["leader_#{feature}"]
+ end
+
+ def percentage_score(feature)
+ return 100 if leader_score(feature).zero?
+
+ 100 * instance_score(feature) / leader_score(feature)
+ end
+ end
+end
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index f83d9e8edee..85e7901dfee 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -4,7 +4,7 @@ class Deployment < ActiveRecord::Base
belongs_to :project, required: true, validate: true
belongs_to :environment, required: true, validate: true
belongs_to :user
- belongs_to :deployable, polymorphic: true
+ belongs_to :deployable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :sha, presence: true
validates :ref, presence: true
@@ -12,6 +12,7 @@ class Deployment < ActiveRecord::Base
delegate :name, to: :environment, prefix: true
after_create :create_ref
+ after_create :invalidate_cache
def commit
project.commit(sha)
@@ -33,6 +34,10 @@ class Deployment < ActiveRecord::Base
project.repository.create_ref(ref, ref_path)
end
+ def invalidate_cache
+ environment.expire_etag_cache
+ end
+
def manual_actions
@manual_actions ||= deployable.try(:other_actions)
end
@@ -103,15 +108,10 @@ class Deployment < ActiveRecord::Base
project.monitoring_service.present?
end
- def metrics(timeframe)
+ def metrics
return {} unless has_metrics?
- half_timeframe = timeframe / 2
- timeframe_start = created_at - half_timeframe
- timeframe_end = created_at + half_timeframe
-
- metrics = project.monitoring_service.metrics(environment, timeframe_start: timeframe_start, timeframe_end: timeframe_end)
- metrics&.merge(deployment_time: created_at.to_i) || {}
+ project.monitoring_service.deployment_metrics(self)
end
private
diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb
index d627fbe327f..07c4846e2ac 100644
--- a/app/models/diff_discussion.rb
+++ b/app/models/diff_discussion.rb
@@ -10,6 +10,7 @@ class DiffDiscussion < Discussion
delegate :position,
:original_position,
+ :change_position,
to: :first_note
@@ -19,27 +20,15 @@ class DiffDiscussion < Discussion
def merge_request_version_params
return unless for_merge_request?
+ return {} if active?
- 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
+ noteable.version_params_for(position.diff_refs)
end
def reply_attributes
super.merge(
original_position: original_position.to_json,
- position: position.to_json,
+ position: position.to_json
)
end
end
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index 76c59199afd..20ef1378500 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -6,8 +6,9 @@ class DiffNote < Note
NOTEABLE_TYPES = %w(MergeRequest Commit).freeze
- serialize :original_position, Gitlab::Diff::Position
- serialize :position, Gitlab::Diff::Position
+ serialize :original_position, Gitlab::Diff::Position # rubocop:disable Cop/ActiverecordSerialize
+ serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiverecordSerialize
+ serialize :change_position, Gitlab::Diff::Position # rubocop:disable Cop/ActiverecordSerialize
validates :original_position, presence: true
validates :position, presence: true
@@ -25,7 +26,7 @@ class DiffNote < Note
DiffDiscussion
end
- %i(original_position position).each do |meth|
+ %i(original_position position change_position).each do |meth|
define_method "#{meth}=" do |new_position|
if new_position.is_a?(String)
new_position = JSON.parse(new_position) rescue nil
@@ -36,6 +37,8 @@ class DiffNote < Note
new_position = Gitlab::Diff::Position.new(new_position)
end
+ return if new_position == read_attribute(meth)
+
super(new_position)
end
end
@@ -45,7 +48,7 @@ class DiffNote < Note
end
def diff_line
- @diff_line ||= diff_file.line_for_position(self.original_position) if diff_file
+ @diff_line ||= diff_file&.line_for_position(self.original_position)
end
def for_line?(line)
@@ -60,7 +63,7 @@ class DiffNote < Note
return false unless supported?
return true if for_commit?
- diff_refs ||= noteable_diff_refs
+ diff_refs ||= noteable.diff_refs
self.position.diff_refs == diff_refs
end
@@ -92,13 +95,21 @@ class DiffNote < Note
return if active?
- Notes::DiffPositionUpdateService.new(
- self.project,
- nil,
+ tracer = Gitlab::Diff::PositionTracer.new(
+ project: self.project,
old_diff_refs: self.position.diff_refs,
- new_diff_refs: noteable_diff_refs,
+ new_diff_refs: self.noteable.diff_refs,
paths: self.position.paths
- ).execute(self)
+ )
+
+ result = tracer.trace(self.position)
+ return unless result
+
+ if result[:outdated]
+ self.change_position = result[:position]
+ else
+ self.position = result[:position]
+ end
end
def verify_supported
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index 0b6b920ed66..d1cec7613af 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -21,7 +21,8 @@ class Discussion
end
def self.build_collection(notes, context_noteable = nil)
- notes.group_by { |n| n.discussion_id(context_noteable) }.values.map { |notes| build(notes, context_noteable) }
+ grouped_notes = notes.group_by { |n| n.discussion_id(context_noteable) }
+ grouped_notes.values.map { |notes| build(notes, context_noteable) }
end
# Returns an alphanumeric discussion ID based on `build_discussion_id`
@@ -84,6 +85,12 @@ class Discussion
first_note.discussion_id(context_noteable)
end
+ def reply_id
+ # To reply to this discussion, we need the actual discussion_id from the database,
+ # not the potentially overwritten one based on the noteable.
+ first_note.discussion_id
+ end
+
alias_method :to_param, :id
def diff_discussion?
diff --git a/app/models/environment.rb b/app/models/environment.rb
index bf33010fd21..6211a5c1e63 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -57,12 +57,16 @@ class Environment < ActiveRecord::Base
state :available
state :stopped
+
+ after_transition do |environment|
+ environment.expire_etag_cache
+ end
end
def predefined_variables
[
{ key: 'CI_ENVIRONMENT_NAME', value: name, public: true },
- { key: 'CI_ENVIRONMENT_SLUG', value: slug, public: true },
+ { key: 'CI_ENVIRONMENT_SLUG', value: slug, public: true }
]
end
@@ -150,7 +154,7 @@ class Environment < ActiveRecord::Base
end
def metrics
- project.monitoring_service.metrics(self) if has_metrics?
+ project.monitoring_service.environment_metrics(self) if has_metrics?
end
# An environment name is not necessarily suitable for use in URLs, DNS
@@ -196,6 +200,18 @@ class Environment < ActiveRecord::Base
[external_url, public_path].join('/')
end
+ def expire_etag_cache
+ Gitlab::EtagCaching::Store.new.tap do |store|
+ store.touch(etag_cache_key)
+ end
+ end
+
+ def etag_cache_key
+ Gitlab::Routing.url_helpers.namespace_project_environments_path(
+ project.namespace,
+ project)
+ end
+
private
# Slugifying a name may remove the uniqueness guarantee afforded by it being
diff --git a/app/models/event.rb b/app/models/event.rb
index e6fad46077a..fad6ff03927 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -14,6 +14,30 @@ class Event < ActiveRecord::Base
DESTROYED = 10
EXPIRED = 11 # User left project due to expiry
+ ACTIONS = HashWithIndifferentAccess.new(
+ created: CREATED,
+ updated: UPDATED,
+ closed: CLOSED,
+ reopened: REOPENED,
+ pushed: PUSHED,
+ commented: COMMENTED,
+ merged: MERGED,
+ joined: JOINED,
+ left: LEFT,
+ destroyed: DESTROYED,
+ expired: EXPIRED
+ ).freeze
+
+ TARGET_TYPES = HashWithIndifferentAccess.new(
+ issue: Issue,
+ milestone: Milestone,
+ merge_request: MergeRequest,
+ note: Note,
+ project: Project,
+ snippet: Snippet,
+ user: User
+ ).freeze
+
RESET_PROJECT_ACTIVITY_INTERVAL = 1.hour
delegate :name, :email, :public_email, :username, to: :author, prefix: true, allow_nil: true
@@ -23,10 +47,10 @@ class Event < ActiveRecord::Base
belongs_to :author, class_name: "User"
belongs_to :project
- belongs_to :target, polymorphic: true
+ belongs_to :target, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
# For Hash only
- serialize :data
+ serialize :data # rubocop:disable Cop/ActiverecordSerialize
# Callbacks
after_create :reset_project_activity
@@ -55,6 +79,14 @@ class Event < ActiveRecord::Base
def limit_recent(limit = 20, offset = nil)
recent.limit(limit).offset(offset)
end
+
+ def actions
+ ACTIONS.keys
+ end
+
+ def target_types
+ TARGET_TYPES.keys
+ end
end
def visible_to_user?(user = nil)
diff --git a/app/models/group.rb b/app/models/group.rb
index fac5843f75c..5bb2cdc5eff 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -4,6 +4,7 @@ class Group < Namespace
include Gitlab::ConfigHelper
include Gitlab::VisibilityLevel
include AccessRequestable
+ include Avatarable
include Referable
include SelectForProjectAuthorization
@@ -115,10 +116,10 @@ class Group < Namespace
allowed_by_projects
end
- def avatar_url(size = nil)
- if self[:avatar].present?
- [gitlab_config.url, avatar.url].join
- end
+ def avatar_url(**args)
+ # We use avatar_path instead of overriding avatar_url because of carrierwave.
+ # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864
+ avatar_path(args)
end
def lfs_enabled?
@@ -221,6 +222,16 @@ class Group < Namespace
User.where(id: members_with_parents.select(:user_id))
end
+ def max_member_access_for_user(user)
+ return GroupMember::OWNER if user.admin?
+
+ members_with_parents.
+ where(user_id: user).
+ reorder(access_level: :desc).
+ first&.
+ access_level || GroupMember::NO_ACCESS
+ end
+
def mattermost_team_params
max_length = 59
diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb
index c631e7a7df5..ee6165fd32d 100644
--- a/app/models/hooks/project_hook.rb
+++ b/app/models/hooks/project_hook.rb
@@ -5,7 +5,7 @@ class ProjectHook < WebHook
scope :confidential_issue_hooks, -> { where(confidential_issues_events: true) }
scope :note_hooks, -> { where(note_events: true) }
scope :merge_request_hooks, -> { where(merge_requests_events: true) }
- scope :build_hooks, -> { where(build_events: true) }
+ scope :job_hooks, -> { where(job_events: true) }
scope :pipeline_hooks, -> { where(pipeline_events: true) }
scope :wiki_page_hooks, -> { where(wiki_page_events: true) }
end
diff --git a/app/models/hooks/service_hook.rb b/app/models/hooks/service_hook.rb
index eef24052a06..40e43c27f91 100644
--- a/app/models/hooks/service_hook.rb
+++ b/app/models/hooks/service_hook.rb
@@ -2,6 +2,6 @@ class ServiceHook < WebHook
belongs_to :service
def execute(data)
- super(data, 'service_hook')
+ WebHookService.new(self, data, 'service_hook').execute
end
end
diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb
index 777bad1e724..1584235ab00 100644
--- a/app/models/hooks/system_hook.rb
+++ b/app/models/hooks/system_hook.rb
@@ -1,5 +1,6 @@
class SystemHook < WebHook
- def async_execute(data, hook_name)
- Sidekiq::Client.enqueue(SystemHookWorker, id, data, hook_name)
- end
+ scope :repository_update_hooks, -> { where(repository_update_events: true) }
+
+ default_value_for :push_events, false
+ default_value_for :repository_update_events, true
end
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index 595602e80fe..7503f3739c3 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -1,6 +1,5 @@
class WebHook < ActiveRecord::Base
include Sortable
- include HTTParty
default_value_for :push_events, true
default_value_for :issues_events, false
@@ -8,56 +7,23 @@ class WebHook < ActiveRecord::Base
default_value_for :note_events, false
default_value_for :merge_requests_events, false
default_value_for :tag_push_events, false
- default_value_for :build_events, false
+ default_value_for :job_events, false
default_value_for :pipeline_events, false
+ default_value_for :repository_update_events, false
default_value_for :enable_ssl_verification, true
+ has_many :web_hook_logs, dependent: :destroy
+
scope :push_hooks, -> { where(push_events: true) }
scope :tag_push_hooks, -> { where(tag_push_events: true) }
- # HTTParty timeout
- default_timeout Gitlab.config.gitlab.webhook_timeout
-
validates :url, presence: true, url: true
def execute(data, hook_name)
- parsed_url = URI.parse(url)
- if parsed_url.userinfo.blank?
- response = WebHook.post(url,
- body: data.to_json,
- headers: build_headers(hook_name),
- verify: enable_ssl_verification)
- else
- post_url = url.gsub("#{parsed_url.userinfo}@", '')
- auth = {
- username: CGI.unescape(parsed_url.user),
- password: CGI.unescape(parsed_url.password),
- }
- response = WebHook.post(post_url,
- body: data.to_json,
- headers: build_headers(hook_name),
- verify: enable_ssl_verification,
- basic_auth: auth)
- end
-
- [response.code, response.to_s]
- rescue SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout => e
- logger.error("WebHook Error => #{e}")
- [false, e.to_s]
+ WebHookService.new(self, data, hook_name).execute
end
def async_execute(data, hook_name)
- Sidekiq::Client.enqueue(ProjectWebHookWorker, id, data, hook_name)
- end
-
- private
-
- def build_headers(hook_name)
- headers = {
- 'Content-Type' => 'application/json',
- 'X-Gitlab-Event' => hook_name.singularize.titleize
- }
- headers['X-Gitlab-Token'] = token if token.present?
- headers
+ WebHookService.new(self, data, hook_name).async_execute
end
end
diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb
new file mode 100644
index 00000000000..d73cfcf630d
--- /dev/null
+++ b/app/models/hooks/web_hook_log.rb
@@ -0,0 +1,13 @@
+class WebHookLog < ActiveRecord::Base
+ belongs_to :web_hook
+
+ serialize :request_headers, Hash # rubocop:disable Cop/ActiverecordSerialize
+ serialize :request_data, Hash # rubocop:disable Cop/ActiverecordSerialize
+ serialize :response_headers, Hash # rubocop:disable Cop/ActiverecordSerialize
+
+ validates :web_hook, presence: true
+
+ def success?
+ response_status =~ /^2/
+ end
+end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 27e3ed9bc7f..693cc21bb40 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -174,7 +174,7 @@ class Issue < ActiveRecord::Base
# Returns boolean if a related branch exists for the current issue
# ignores merge requests branchs
- def has_related_branch?
+ def has_related_branch?
project.repository.branch_names.any? do |branch|
/\A#{iid}-(?!\d+-stable)/i =~ branch
end
@@ -251,9 +251,9 @@ class Issue < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
- json[:subscribed] = subscribed?(options[:user], project) if options.has_key?(:user) && options[:user]
+ json[:subscribed] = subscribed?(options[:user], project) if options.key?(:user) && options[:user]
- if options.has_key?(:labels)
+ if options.key?(:labels)
json[:labels] = labels.as_json(
project: project,
only: [:id, :title, :description, :color, :priority],
@@ -292,7 +292,7 @@ class Issue < ActiveRecord::Base
end
def expire_etag_cache
- key = Gitlab::Routing.url_helpers.rendered_title_namespace_project_issue_path(
+ key = Gitlab::Routing.url_helpers.realtime_changes_namespace_project_issue_path(
project.namespace,
project,
self
diff --git a/app/models/key.rb b/app/models/key.rb
index 9c74ca84753..cb8f10f6d55 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -1,7 +1,6 @@
require 'digest/md5'
class Key < ActiveRecord::Base
- include AfterCommitQueue
include Sortable
LAST_USED_AT_REFRESH_TIME = 1.day.to_i
@@ -25,10 +24,10 @@ class Key < ActiveRecord::Base
delegate :name, :email, to: :user, prefix: true
- after_create :add_to_shell
- after_create :notify_user
+ after_commit :add_to_shell, on: :create
+ after_commit :notify_user, on: :create
after_create :post_create_hook
- after_destroy :remove_from_shell
+ after_commit :remove_from_shell, on: :destroy
after_destroy :post_destroy_hook
def key=(value)
@@ -74,7 +73,7 @@ class Key < ActiveRecord::Base
GitlabShellWorker.perform_async(
:remove_key,
shell_id,
- key,
+ key
)
end
@@ -93,6 +92,6 @@ class Key < ActiveRecord::Base
end
def notify_user
- run_after_commit { NotificationService.new.new_key(self) }
+ NotificationService.new.new_key(self)
end
end
diff --git a/app/models/label.rb b/app/models/label.rb
index ddddb6bdf8f..955d6b4079b 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -133,6 +133,10 @@ class Label < ActiveRecord::Base
template
end
+ def color
+ super || DEFAULT_COLOR
+ end
+
def text_color
LabelsHelper.text_color_for_bg(self.color)
end
@@ -168,7 +172,7 @@ class Label < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
- json[:priority] = priority(options[:project]) if options.has_key?(:project)
+ json[:priority] = priority(options[:project]) if options.key?(:project)
end
end
diff --git a/app/models/label_link.rb b/app/models/label_link.rb
index 51b5c2b1f4c..d68e1f54317 100644
--- a/app/models/label_link.rb
+++ b/app/models/label_link.rb
@@ -1,7 +1,7 @@
class LabelLink < ActiveRecord::Base
include Importable
- belongs_to :target, polymorphic: true
+ belongs_to :target, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :label
validates :target, presence: true, unless: :importing?
diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb
index d7c627432d2..7126de2d488 100644
--- a/app/models/legacy_diff_note.rb
+++ b/app/models/legacy_diff_note.rb
@@ -7,7 +7,7 @@
class LegacyDiffNote < Note
include NoteOnDiff
- serialize :st_diff
+ serialize :st_diff # rubocop:disable Cop/ActiverecordSerialize
validates :line_code, presence: true, line_code: true
@@ -61,7 +61,7 @@ class LegacyDiffNote < Note
return true if for_commit?
return true unless diff_line
return false unless noteable
- return false if diff_refs && diff_refs != noteable_diff_refs
+ return false if diff_refs && diff_refs != noteable.diff_refs
noteable_diff = find_noteable_diff
diff --git a/app/models/lfs_objects_project.rb b/app/models/lfs_objects_project.rb
index 007eed5600a..b0625c52b62 100644
--- a/app/models/lfs_objects_project.rb
+++ b/app/models/lfs_objects_project.rb
@@ -6,8 +6,7 @@ class LfsObjectsProject < ActiveRecord::Base
validates :lfs_object_id, uniqueness: { scope: [:project_id], message: "already exists in project" }
validates :project_id, presence: true
- after_create :update_project_statistics
- after_destroy :update_project_statistics
+ after_commit :update_project_statistics, on: [:create, :destroy]
private
diff --git a/app/models/list.rb b/app/models/list.rb
index fbd19acd1f5..918275be142 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -2,7 +2,7 @@ class List < ActiveRecord::Base
belongs_to :board
belongs_to :label
- enum list_type: { label: 1, closed: 2 }
+ enum list_type: { backlog: 0, label: 1, closed: 2 }
validates :board, :list_type, presence: true
validates :label, :position, presence: true, if: :label?
@@ -28,7 +28,7 @@ class List < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
- if options.has_key?(:label)
+ if options.key?(:label)
json[:label] = label.as_json(
project: board.project,
only: [:id, :title, :description, :color]
diff --git a/app/models/member.rb b/app/models/member.rb
index 7228e82e978..788a32dd8e3 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -8,7 +8,7 @@ class Member < ActiveRecord::Base
belongs_to :created_by, class_name: "User"
belongs_to :user
- belongs_to :source, polymorphic: true
+ belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
delegate :name, :username, :email, to: :user, prefix: true
@@ -200,6 +200,10 @@ class Member < ActiveRecord::Base
source_type
end
+ def access_field
+ access_level
+ end
+
def invite?
self.invite_token.present?
end
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index 28e10bc6172..47040f95533 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -25,10 +25,6 @@ class GroupMember < Member
source
end
- def access_field
- access_level
- end
-
# Because source_type is `Namespace`...
def real_source_type
'Group'
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index b3a91feb091..c0e17f4bfc8 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -79,10 +79,6 @@ class ProjectMember < Member
end
end
- def access_field
- access_level
- end
-
def project
source
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 2f3e4bf894a..dd155252ad5 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -13,13 +13,15 @@ class MergeRequest < ActiveRecord::Base
has_one :merge_request_diff,
-> { order('merge_request_diffs.id DESC') }
+ belongs_to :head_pipeline, foreign_key: "head_pipeline_id", class_name: "Ci::Pipeline"
+
has_many :events, as: :target, dependent: :destroy
has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all
belongs_to :assignee, class_name: "User"
- serialize :merge_params, Hash
+ serialize :merge_params, Hash # rubocop:disable Cop/ActiverecordSerialize
after_create :ensure_merge_request_diff, unless: :importing?
after_update :reload_diff_if_branch_changed
@@ -218,10 +220,10 @@ class MergeRequest < ActiveRecord::Base
def diffs(diff_options = {})
if compare
- # When saving MR diffs, `no_collapse` is implicitly added (because we need
+ # When saving MR diffs, `expanded` is implicitly added (because we need
# to save the entire contents to the DB), so add that here for
# consistency.
- compare.diffs(diff_options.merge(no_collapse: true))
+ compare.diffs(diff_options.merge(expanded: true))
else
merge_request_diff.diffs(diff_options)
end
@@ -243,19 +245,6 @@ class MergeRequest < ActiveRecord::Base
end
end
- # MRs created before 8.4 don't store a MergeRequestDiff#base_commit_sha,
- # but we need to get a commit for the "View file @ ..." link by deleted files,
- # so we find the likely one if we can't get the actual one.
- # This will not be the actual base commit if the target branch was merged into
- # the source branch after the merge request was created, but it is good enough
- # for the specific purpose of linking to a commit.
- # It is not good enough for use in `Gitlab::Git::DiffRefs`, which needs the
- # true base commit, so we can't simply have `#diff_base_commit` fall back on
- # this method.
- def likely_diff_base_commit
- first_commit.try(:parent) || first_commit
- end
-
def diff_start_commit
if persisted?
merge_request_diff.start_commit
@@ -320,21 +309,14 @@ class MergeRequest < ActiveRecord::Base
end
def diff_refs
- return unless diff_start_commit || diff_base_commit
-
- Gitlab::Diff::DiffRefs.new(
- base_sha: diff_base_sha,
- start_sha: diff_start_sha,
- head_sha: diff_head_sha
- )
- end
-
- # Return diff_refs instance trying to not touch the git repository
- def diff_sha_refs
- if merge_request_diff && merge_request_diff.diff_refs_by_sha?
+ if persisted?
merge_request_diff.diff_refs
else
- diff_refs
+ Gitlab::Diff::DiffRefs.new(
+ base_sha: diff_base_sha,
+ start_sha: diff_start_sha,
+ head_sha: diff_head_sha
+ )
end
end
@@ -414,13 +396,24 @@ class MergeRequest < ActiveRecord::Base
@merge_request_diffs_by_diff_refs_or_sha[diff_refs_or_sha]
end
+ def version_params_for(diff_refs)
+ if diff = merge_request_diff_for(diff_refs)
+ { diff_id: diff.id }
+ elsif diff = merge_request_diff_for(diff_refs.head_sha)
+ {
+ diff_id: diff.id,
+ start_sha: diff_refs.start_sha
+ }
+ end
+ end
+
def reload_diff_if_branch_changed
if source_branch_changed? || target_branch_changed?
reload_diff
end
end
- def reload_diff
+ def reload_diff(current_user = nil)
return unless open?
old_diff_refs = self.diff_refs
@@ -428,9 +421,10 @@ class MergeRequest < ActiveRecord::Base
MergeRequests::MergeRequestDiffCacheService.new.execute(self)
new_diff_refs = self.diff_refs
- update_diff_notes_positions(
+ update_diff_discussion_positions(
old_diff_refs: old_diff_refs,
- new_diff_refs: new_diff_refs
+ new_diff_refs: new_diff_refs,
+ current_user: current_user
)
end
@@ -823,12 +817,6 @@ class MergeRequest < ActiveRecord::Base
diverged_commits_count > 0
end
- def head_pipeline
- return unless diff_head_sha && source_project
-
- @head_pipeline ||= source_project.pipeline_for(source_branch, diff_head_sha)
- end
-
def all_pipelines
return Ci::Pipeline.none unless source_project
@@ -862,34 +850,30 @@ class MergeRequest < ActiveRecord::Base
end
def has_complete_diff_refs?
- diff_sha_refs && diff_sha_refs.complete?
+ diff_refs && diff_refs.complete?
end
- def update_diff_notes_positions(old_diff_refs:, new_diff_refs:)
+ def update_diff_discussion_positions(old_diff_refs:, new_diff_refs:, current_user: nil)
return unless has_complete_diff_refs?
return if new_diff_refs == old_diff_refs
- active_diff_notes = self.notes.new_diff_notes.select do |note|
- note.active?(old_diff_refs)
+ active_diff_discussions = self.notes.new_diff_notes.discussions.select do |discussion|
+ discussion.active?(old_diff_refs)
end
+ return if active_diff_discussions.empty?
- return if active_diff_notes.empty?
-
- paths = active_diff_notes.flat_map { |n| n.diff_file.paths }.uniq
+ paths = active_diff_discussions.flat_map { |n| n.diff_file.paths }.uniq
- service = Notes::DiffPositionUpdateService.new(
+ service = Discussions::UpdateDiffPositionService.new(
self.project,
- nil,
+ current_user,
old_diff_refs: old_diff_refs,
new_diff_refs: new_diff_refs,
paths: paths
)
- transaction do
- active_diff_notes.each do |note|
- service.execute(note)
- Gitlab::Timeless.timeless(note, &:save)
- end
+ active_diff_discussions.each do |discussion|
+ service.execute(discussion)
end
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index f0a3c30ea74..99dd2130188 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -1,7 +1,7 @@
class MergeRequestDiff < ActiveRecord::Base
include Sortable
include Importable
- include Gitlab::Git::EncodingHelper
+ include Gitlab::EncodingHelper
# Prevent store of diff if commits amount more then 500
COMMITS_SAFE_SIZE = 100
@@ -11,8 +11,8 @@ class MergeRequestDiff < ActiveRecord::Base
belongs_to :merge_request
- serialize :st_commits
- serialize :st_diffs
+ serialize :st_commits # rubocop:disable Cop/ActiverecordSerialize
+ serialize :st_diffs # rubocop:disable Cop/ActiverecordSerialize
state_machine :state, initial: :empty do
state :collected
@@ -150,6 +150,29 @@ class MergeRequestDiff < ActiveRecord::Base
)
end
+ # MRs created before 8.4 don't store their true diff refs (start and base),
+ # but we need to get a commit SHA for the "View file @ ..." link by a file,
+ # so we use an approximation of the diff refs if we can't get the actual one.
+ #
+ # These will not be the actual diff refs if the target branch was merged into
+ # the source branch after the merge request was created, but it is good enough
+ # for the specific purpose of linking to a commit.
+ #
+ # It is not good enough for highlighting diffs, so we can't simply pass
+ # these as `diff_refs.`
+ def fallback_diff_refs
+ real_refs = diff_refs
+ return real_refs if real_refs
+
+ likely_base_commit_sha = (first_commit&.parent || first_commit)&.sha
+
+ Gitlab::Diff::DiffRefs.new(
+ base_sha: likely_base_commit_sha,
+ start_sha: safe_start_commit_sha,
+ head_sha: head_commit_sha
+ )
+ end
+
def diff_refs_by_sha?
base_commit_sha? && head_commit_sha? && start_commit_sha?
end
@@ -175,12 +198,11 @@ class MergeRequestDiff < ActiveRecord::Base
self == merge_request.merge_request_diff
end
- def compare_with(sha, straight: true)
+ def compare_with(sha)
# When compare merge request versions we want diff A..B instead of A...B
# so we handle cases when user does squash and rebase of the commits between versions.
# For this reason we set straight to true by default.
- CompareService.new(project, head_commit_sha)
- .execute(project, sha, straight: straight)
+ CompareService.new(project, head_commit_sha).execute(project, sha, straight: true)
end
def commits_count
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index c06bfe0ccdd..b04bed4c014 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -107,7 +107,7 @@ class Milestone < ActiveRecord::Base
end
def participants
- User.joins(assigned_issues: :milestone).where("milestones.id = ?", id)
+ User.joins(assigned_issues: :milestone).where("milestones.id = ?", id).uniq
end
def self.sort(method)
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 9bdfab9a066..b48d73dcae7 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -6,6 +6,7 @@ class Namespace < ActiveRecord::Base
include Gitlab::ShellAdapter
include Gitlab::CurrentSettings
include Routable
+ include AfterCommitQueue
# Prevent users from creating unreasonably deep level of nesting.
# The number 20 was taken based on maximum nesting level of
@@ -56,7 +57,7 @@ class Namespace < ActiveRecord::Base
'COALESCE(SUM(ps.storage_size), 0) AS storage_size',
'COALESCE(SUM(ps.repository_size), 0) AS repository_size',
'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size',
- 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size',
+ 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size'
)
end
@@ -242,7 +243,9 @@ class Namespace < ActiveRecord::Base
# Remove namespace directroy async with delay so
# GitLab has time to remove all projects first
- GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage_path, new_path)
+ run_after_commit do
+ GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage_path, new_path)
+ end
end
end
diff --git a/app/models/note.rb b/app/models/note.rb
index 4cb3c6f062a..244bf169c29 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -41,7 +41,7 @@ class Note < ActiveRecord::Base
participant :author
belongs_to :project
- belongs_to :noteable, polymorphic: true, touch: true
+ belongs_to :noteable, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :author, class_name: "User"
belongs_to :updated_by, class_name: "User"
belongs_to :last_edited_by, class_name: 'User'
@@ -111,7 +111,7 @@ class Note < ActiveRecord::Base
end
def discussions(context_noteable = nil)
- Discussion.build_collection(fresh, context_noteable)
+ Discussion.build_collection(all.includes(:noteable).fresh, context_noteable)
end
def find_discussion(discussion_id)
@@ -125,13 +125,12 @@ class Note < ActiveRecord::Base
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
+ line_code = discussion.line_code_in_diffs(diff_refs)
- discussions << discussion if discussions
+ if line_code
+ discussions = groups[line_code] ||= []
+ discussions << discussion
+ end
end
groups
diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb
index e4726e62e93..42412a9a44b 100644
--- a/app/models/notification_setting.rb
+++ b/app/models/notification_setting.rb
@@ -4,7 +4,7 @@ class NotificationSetting < ActiveRecord::Base
default_value_for :level, NotificationSetting.levels[:global]
belongs_to :user
- belongs_to :source, polymorphic: true
+ belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :project, foreign_key: 'source_id'
validates :user, presence: true
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index f2f2fc1e32a..5d798247863 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -1,7 +1,7 @@
class PagesDomain < ActiveRecord::Base
belongs_to :project
- validates :domain, hostname: true
+ validates :domain, hostname: { allow_numeric_hostname: true }
validates :domain, uniqueness: { case_sensitive: false }
validates :certificate, certificate: true, allow_nil: true, allow_blank: true
validates :key, certificate_key: true, allow_nil: true, allow_blank: true
@@ -98,7 +98,7 @@ class PagesDomain < ActiveRecord::Base
def validate_pages_domain
return unless domain
- if domain.downcase.ends_with?(".#{Settings.pages.host}".downcase)
+ if domain.downcase.ends_with?(Settings.pages.host.downcase)
self.errors.add(:domain, "*.#{Settings.pages.host} is restricted")
end
end
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index e8b000ddad6..6e13f9b2089 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -3,7 +3,7 @@ class PersonalAccessToken < ActiveRecord::Base
include TokenAuthenticatable
add_authentication_token_field :token
- serialize :scopes, Array
+ serialize :scopes, Array # rubocop:disable Cop/ActiverecordSerialize
belongs_to :user
@@ -15,11 +15,10 @@ class PersonalAccessToken < ActiveRecord::Base
scope :without_impersonation, -> { where(impersonation: false) }
validates :scopes, presence: true
- validate :validate_api_scopes
+ validate :validate_scopes
def revoke!
- self.revoked = true
- self.save
+ update!(revoked: true)
end
def active?
@@ -28,9 +27,9 @@ class PersonalAccessToken < ActiveRecord::Base
protected
- def validate_api_scopes
- unless scopes.all? { |scope| Gitlab::Auth::API_SCOPES.include?(scope.to_sym) }
- errors.add :scopes, "can only contain API scopes"
+ def validate_scopes
+ unless scopes.all? { |scope| Gitlab::Auth::AVAILABLE_SCOPES.include?(scope.to_sym) }
+ errors.add :scopes, "can only contain available scopes"
end
end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index 7722db5375b..0caf7387450 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -6,6 +6,7 @@ class Project < ActiveRecord::Base
include Gitlab::VisibilityLevel
include Gitlab::CurrentSettings
include AccessRequestable
+ include Avatarable
include CacheMarkdownField
include Referable
include Sortable
@@ -164,7 +165,7 @@ class Project < ActiveRecord::Base
has_many :todos, dependent: :destroy
has_many :notification_settings, dependent: :destroy, as: :source
- has_one :import_data, dependent: :destroy, class_name: "ProjectImportData"
+ has_one :import_data, dependent: :delete, class_name: "ProjectImportData"
has_one :project_feature, dependent: :destroy
has_one :statistics, class_name: 'ProjectStatistics', dependent: :delete
has_many :container_repositories, dependent: :destroy
@@ -174,7 +175,7 @@ class Project < ActiveRecord::Base
has_many :builds, class_name: 'Ci::Build' # the builds are created from the commit_statuses
has_many :runner_projects, dependent: :destroy, class_name: 'Ci::RunnerProject'
has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
- has_many :variables, dependent: :destroy, class_name: 'Ci::Variable'
+ has_many :variables, class_name: 'Ci::Variable'
has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger'
has_many :environments, dependent: :destroy
has_many :deployments, dependent: :destroy
@@ -204,8 +205,8 @@ class Project < ActiveRecord::Base
presence: true,
dynamic_path: true,
length: { maximum: 255 },
- format: { with: Gitlab::Regex.project_path_format_regex,
- message: Gitlab::Regex.project_path_regex_message },
+ format: { with: Gitlab::PathRegex.project_path_format_regex,
+ message: Gitlab::PathRegex.project_path_format_message },
uniqueness: { scope: :namespace_id }
validates :namespace, presence: true
@@ -241,6 +242,7 @@ class Project < ActiveRecord::Base
scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) }
scope :personal, ->(user) { where(namespace_id: user.namespace_id) }
scope :joined, ->(user) { where('namespace_id != ?', user.namespace_id) }
+ scope :starred_by, ->(user) { joins(:users_star_projects).where('users_star_projects.user_id': user.id) }
scope :visible_to_user, ->(user) { where(id: user.authorized_projects.select(:id).reorder(nil)) }
scope :non_archived, -> { where(archived: false) }
scope :for_milestones, ->(ids) { joins(:milestones).where('milestones.id' => ids).distinct }
@@ -270,6 +272,7 @@ class Project < ActiveRecord::Base
scope :with_builds_enabled, -> { with_feature_enabled(:builds) }
scope :with_issues_enabled, -> { with_feature_enabled(:issues) }
+ scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) }
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
@@ -295,8 +298,16 @@ class Project < ActiveRecord::Base
scope :excluding_project, ->(project) { where.not(id: project) }
state_machine :import_status, initial: :none do
+ event :import_schedule do
+ transition [:none, :finished, :failed] => :scheduled
+ end
+
+ event :force_import_start do
+ transition [:none, :finished, :failed] => :started
+ end
+
event :import_start do
- transition [:none, :finished] => :started
+ transition scheduled: :started
end
event :import_finish do
@@ -304,18 +315,23 @@ class Project < ActiveRecord::Base
end
event :import_fail do
- transition started: :failed
+ transition [:scheduled, :started] => :failed
end
event :import_retry do
transition failed: :started
end
+ state :scheduled
state :started
state :finished
state :failed
- after_transition any => :finished, do: :reset_cache_and_import_attrs
+ after_transition [:none, :finished, :failed] => :scheduled do |project, _|
+ project.run_after_commit { add_import_job }
+ end
+
+ after_transition started: :finished, do: :reset_cache_and_import_attrs
end
class << self
@@ -348,10 +364,6 @@ class Project < ActiveRecord::Base
where("projects.id IN (#{union.to_sql})")
end
- def search_by_visibility(level)
- where(visibility_level: Gitlab::VisibilityLevel.string_options[level])
- end
-
def search_by_title(query)
pattern = "%#{query}%"
table = Project.arel_table
@@ -379,11 +391,9 @@ class Project < ActiveRecord::Base
end
def reference_pattern
- name_pattern = Gitlab::Regex::FULL_NAMESPACE_REGEX_STR
-
%r{
- ((?<namespace>#{name_pattern})\/)?
- (?<project>#{name_pattern})
+ ((?<namespace>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})\/)?
+ (?<project>#{Gitlab::PathRegex::PROJECT_PATH_FORMAT_REGEX})
}x
end
@@ -474,7 +484,9 @@ class Project < ActiveRecord::Base
end
def reset_cache_and_import_attrs
- ProjectCacheWorker.perform_async(self.id)
+ run_after_commit do
+ ProjectCacheWorker.perform_async(self.id)
+ end
self.import_data&.destroy
end
@@ -533,9 +545,17 @@ class Project < ActiveRecord::Base
end
def import_in_progress?
+ import_started? || import_scheduled?
+ end
+
+ def import_started?
import? && import_status == 'started'
end
+ def import_scheduled?
+ import_status == 'scheduled'
+ end
+
def import_failed?
import_status == 'failed'
end
@@ -798,12 +818,10 @@ class Project < ActiveRecord::Base
repository.avatar
end
- def avatar_url
- if self[:avatar].present?
- [gitlab_config.url, avatar.url].join
- elsif avatar_in_git
- Gitlab::Routing.url_helpers.namespace_project_avatar_url(namespace, self)
- end
+ def avatar_url(**args)
+ # We use avatar_path instead of overriding avatar_url because of carrierwave.
+ # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864
+ avatar_path(args) || (Gitlab::Routing.url_helpers.namespace_project_avatar_url(namespace, self) if avatar_in_git)
end
# For compatibility with old code
@@ -876,10 +894,8 @@ class Project < ActiveRecord::Base
url_to_repo
end
- def http_url_to_repo(user = nil)
- credentials = Gitlab::UrlSanitizer.http_credentials_for_user(user)
-
- Gitlab::UrlSanitizer.new("#{web_url}.git", credentials: credentials).full_url
+ def http_url_to_repo
+ "#{web_url}.git"
end
def user_can_push_to_empty_repo?(user)
@@ -968,7 +984,7 @@ class Project < ActiveRecord::Base
namespace: namespace.name,
visibility_level: visibility_level,
path_with_namespace: path_with_namespace,
- default_branch: default_branch,
+ default_branch: default_branch
}
# Backward compatibility
@@ -1068,11 +1084,6 @@ class Project < ActiveRecord::Base
pipelines.order(id: :desc).find_by(sha: sha, ref: ref)
end
- def ensure_pipeline(ref, sha, current_user = nil)
- pipeline_for(ref, sha) ||
- pipelines.create(sha: sha, ref: ref, user: current_user)
- end
-
def enable_ci
project_feature.update_attribute(:builds_access_level, ProjectFeature::ENABLED)
end
@@ -1238,6 +1249,7 @@ class Project < ActiveRecord::Base
{ key: 'CI_PROJECT_ID', value: id.to_s, public: true },
{ key: 'CI_PROJECT_NAME', value: path, public: true },
{ key: 'CI_PROJECT_PATH', value: path_with_namespace, public: true },
+ { key: 'CI_PROJECT_PATH_SLUG', value: path_with_namespace.parameterize, public: true },
{ key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path, public: true },
{ key: 'CI_PROJECT_URL', value: web_url, public: true }
]
@@ -1257,12 +1269,19 @@ class Project < ActiveRecord::Base
variables
end
- def secret_variables
- variables.map do |variable|
- { key: variable.key, value: variable.value, public: false }
+ def secret_variables_for(ref)
+ if protected_for?(ref)
+ variables
+ else
+ variables.unprotected
end
end
+ def protected_for?(ref)
+ ProtectedBranch.protected?(self, ref) ||
+ ProtectedTag.protected?(self, ref)
+ end
+
def deployment_variables
return [] unless deployment_service
diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb
index 331123a5a5b..e3cafd4d1c6 100644
--- a/app/models/project_import_data.rb
+++ b/app/models/project_import_data.rb
@@ -10,7 +10,7 @@ class ProjectImportData < ActiveRecord::Base
insecure_mode: true,
algorithm: 'aes-256-cbc'
- serialize :data, JSON
+ serialize :data, JSON # rubocop:disable Cop/ActiverecordSerialize
validates :project, presence: true
diff --git a/app/models/project_services/asana_service.rb b/app/models/project_services/asana_service.rb
index 3728f5642e4..9ce2d1153a7 100644
--- a/app/models/project_services/asana_service.rb
+++ b/app/models/project_services/asana_service.rb
@@ -34,7 +34,8 @@ http://app.asana.com/-/account_api'
{
type: 'text',
name: 'api_key',
- placeholder: 'User Personal Access Token. User must have access to task, all comments will be attributed to this user.'
+ placeholder: 'User Personal Access Token. User must have access to task, all comments will be attributed to this user.',
+ required: true
},
{
type: 'text',
diff --git a/app/models/project_services/assembla_service.rb b/app/models/project_services/assembla_service.rb
index aeeff8917bf..ae6af732ed4 100644
--- a/app/models/project_services/assembla_service.rb
+++ b/app/models/project_services/assembla_service.rb
@@ -18,7 +18,7 @@ class AssemblaService < Service
def fields
[
- { type: 'text', name: 'token', placeholder: '' },
+ { type: 'text', name: 'token', placeholder: '', required: true },
{ type: 'text', name: 'subdomain', placeholder: '' }
]
end
diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb
index 400020ee04a..42939ea0ec8 100644
--- a/app/models/project_services/bamboo_service.rb
+++ b/app/models/project_services/bamboo_service.rb
@@ -47,12 +47,12 @@ class BambooService < CiService
def fields
[
{ type: 'text', name: 'bamboo_url',
- placeholder: 'Bamboo root URL like https://bamboo.example.com' },
+ placeholder: 'Bamboo root URL like https://bamboo.example.com', required: true },
{ type: 'text', name: 'build_key',
- placeholder: 'Bamboo build plan key like KEY' },
+ placeholder: 'Bamboo build plan key like KEY', required: true },
{ type: 'text', name: 'username',
placeholder: 'A user with API access, if applicable' },
- { type: 'password', name: 'password' },
+ { type: 'password', name: 'password' }
]
end
diff --git a/app/models/project_services/buildkite_service.rb b/app/models/project_services/buildkite_service.rb
index 5fb95050b83..fc30f6e3365 100644
--- a/app/models/project_services/buildkite_service.rb
+++ b/app/models/project_services/buildkite_service.rb
@@ -58,11 +58,11 @@ class BuildkiteService < CiService
[
{ type: 'text',
name: 'token',
- placeholder: 'Buildkite project GitLab token' },
+ placeholder: 'Buildkite project GitLab token', required: true },
{ type: 'text',
name: 'project_url',
- placeholder: "#{ENDPOINT}/example/project" },
+ placeholder: "#{ENDPOINT}/example/project", required: true },
{ type: 'checkbox',
name: 'enable_ssl_verification',
diff --git a/app/models/project_services/campfire_service.rb b/app/models/project_services/campfire_service.rb
index 0de59af5652..c3f5b310619 100644
--- a/app/models/project_services/campfire_service.rb
+++ b/app/models/project_services/campfire_service.rb
@@ -18,7 +18,7 @@ class CampfireService < Service
def fields
[
- { type: 'text', name: 'token', placeholder: '' },
+ { type: 'text', name: 'token', placeholder: '', required: true },
{ type: 'text', name: 'subdomain', placeholder: '' },
{ type: 'text', name: 'room', placeholder: '' }
]
@@ -76,7 +76,7 @@ class CampfireService < Service
# Returns a list of rooms, or [].
# https://github.com/basecamp/campfire-api/blob/master/sections/rooms.md#get-rooms
def rooms(auth)
- res = self.class.get("/rooms.json", auth)
+ res = self.class.get("/rooms.json", auth)
res.code == 200 ? res["rooms"] : []
end
diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb
index 47b68f00cff..3edc395033c 100644
--- a/app/models/project_services/chat_message/pipeline_message.rb
+++ b/app/models/project_services/chat_message/pipeline_message.rb
@@ -35,7 +35,7 @@ module ChatMessage
def activity
{
- title: "Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status}",
+ title: "Pipeline #{pipeline_link} of #{ref_type} #{branch_link} by #{user_name} #{humanized_status}",
subtitle: "in #{project_link}",
text: "in #{pretty_duration(duration)}",
image: user_avatar || ''
@@ -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 #{pretty_duration(duration)}"
+ "#{project_link}: Pipeline #{pipeline_link} of #{ref_type} #{branch_link} by #{user_name} #{humanized_status} in #{pretty_duration(duration)}"
end
def humanized_status
@@ -70,7 +70,7 @@ module ChatMessage
end
def branch_link
- "[#{ref}](#{branch_url})"
+ "`[#{ref}](#{branch_url})`"
end
def project_link
diff --git a/app/models/project_services/chat_message/push_message.rb b/app/models/project_services/chat_message/push_message.rb
index c52dd6ef8ef..04a59d559ca 100644
--- a/app/models/project_services/chat_message/push_message.rb
+++ b/app/models/project_services/chat_message/push_message.rb
@@ -61,7 +61,7 @@ module ChatMessage
end
def removed_branch_message
- "#{user_name} removed #{ref_type} #{ref} from #{project_link}"
+ "#{user_name} removed #{ref_type} `#{ref}` from #{project_link}"
end
def push_message
@@ -102,7 +102,7 @@ module ChatMessage
end
def branch_link
- "[#{ref}](#{branch_url})"
+ "`[#{ref}](#{branch_url})`"
end
def project_link
diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb
index 6464bf3f4a4..6d1a321f651 100644
--- a/app/models/project_services/chat_notification_service.rb
+++ b/app/models/project_services/chat_notification_service.rb
@@ -21,10 +21,6 @@ class ChatNotificationService < Service
end
end
- def can_test?
- valid?
- end
-
def self.supported_events
%w[push issue confidential_issue merge_request note tag_push
pipeline wiki_page]
@@ -36,10 +32,10 @@ class ChatNotificationService < Service
def default_fields
[
- { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" },
+ { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}", required: true },
{ type: 'text', name: 'username', placeholder: 'e.g. GitLab' },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
- { type: 'checkbox', name: 'notify_only_default_branch' },
+ { type: 'checkbox', name: 'notify_only_default_branch' }
]
end
diff --git a/app/models/project_services/custom_issue_tracker_service.rb b/app/models/project_services/custom_issue_tracker_service.rb
index dea915a4d05..b9e3e982b64 100644
--- a/app/models/project_services/custom_issue_tracker_service.rb
+++ b/app/models/project_services/custom_issue_tracker_service.rb
@@ -31,9 +31,9 @@ class CustomIssueTrackerService < IssueTrackerService
[
{ type: 'text', name: 'title', placeholder: title },
{ type: 'text', name: 'description', placeholder: description },
- { type: 'text', name: 'project_url', placeholder: 'Project url' },
- { type: 'text', name: 'issues_url', placeholder: 'Issue url' },
- { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url' }
+ { type: 'text', name: 'project_url', placeholder: 'Project url', required: true },
+ { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true },
+ { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url', required: true }
]
end
end
diff --git a/app/models/project_services/deployment_service.rb b/app/models/project_services/deployment_service.rb
index 91a55514a9a..5b8320158fc 100644
--- a/app/models/project_services/deployment_service.rb
+++ b/app/models/project_services/deployment_service.rb
@@ -30,4 +30,8 @@ class DeploymentService < Service
def terminals(environment)
raise NotImplementedError
end
+
+ def can_test?
+ false
+ end
end
diff --git a/app/models/project_services/drone_ci_service.rb b/app/models/project_services/drone_ci_service.rb
index 2717c240f05..f6cade9c290 100644
--- a/app/models/project_services/drone_ci_service.rb
+++ b/app/models/project_services/drone_ci_service.rb
@@ -93,8 +93,8 @@ class DroneCiService < CiService
def fields
[
- { type: 'text', name: 'token', placeholder: 'Drone CI project specific token' },
- { type: 'text', name: 'drone_url', placeholder: 'http://drone.example.com' },
+ { type: 'text', name: 'token', placeholder: 'Drone CI project specific token', required: true },
+ { type: 'text', name: 'drone_url', placeholder: 'http://drone.example.com', required: true },
{ type: 'checkbox', name: 'enable_ssl_verification', title: "Enable SSL verification" }
]
end
diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb
index f4f913ee0b6..1a236e232f9 100644
--- a/app/models/project_services/emails_on_push_service.rb
+++ b/app/models/project_services/emails_on_push_service.rb
@@ -47,7 +47,7 @@ class EmailsOnPushService < Service
help: "Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. #{domains})." },
{ type: 'checkbox', name: 'disable_diffs', title: "Disable code diffs",
help: "Don't include possibly sensitive code diffs in notification body." },
- { type: 'textarea', name: 'recipients', placeholder: 'Emails separated by whitespace' },
+ { type: 'textarea', name: 'recipients', placeholder: 'Emails separated by whitespace' }
]
end
end
diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb
index bdf6fa6a586..720ad61162e 100644
--- a/app/models/project_services/external_wiki_service.rb
+++ b/app/models/project_services/external_wiki_service.rb
@@ -19,7 +19,7 @@ class ExternalWikiService < Service
def fields
[
- { type: 'text', name: 'external_wiki_url', placeholder: 'The URL of the external Wiki' },
+ { type: 'text', name: 'external_wiki_url', placeholder: 'The URL of the external Wiki', required: true }
]
end
diff --git a/app/models/project_services/flowdock_service.rb b/app/models/project_services/flowdock_service.rb
index 10a13c3fbdc..2db95b9aaa3 100644
--- a/app/models/project_services/flowdock_service.rb
+++ b/app/models/project_services/flowdock_service.rb
@@ -18,7 +18,7 @@ class FlowdockService < Service
def fields
[
- { type: 'text', name: 'token', placeholder: 'Flowdock Git source token' }
+ { type: 'text', name: 'token', placeholder: 'Flowdock Git source token', required: true }
]
end
@@ -37,7 +37,7 @@ class FlowdockService < Service
repo: project.repository.path_to_repo,
repo_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}",
commit_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/commit/%s",
- diff_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/compare/%s...%s",
+ diff_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/compare/%s...%s"
)
end
end
diff --git a/app/models/project_services/gemnasium_service.rb b/app/models/project_services/gemnasium_service.rb
index f271e1f1739..017a9b2df6e 100644
--- a/app/models/project_services/gemnasium_service.rb
+++ b/app/models/project_services/gemnasium_service.rb
@@ -18,8 +18,8 @@ class GemnasiumService < Service
def fields
[
- { type: 'text', name: 'api_key', placeholder: 'Your personal API KEY on gemnasium.com ' },
- { type: 'text', name: 'token', placeholder: 'The project\'s slug on gemnasium.com' }
+ { type: 'text', name: 'api_key', placeholder: 'Your personal API KEY on gemnasium.com ', required: true },
+ { type: 'text', name: 'token', placeholder: 'The project\'s slug on gemnasium.com', required: true }
]
end
diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb
index 8b181221bb0..e3906943ecd 100644
--- a/app/models/project_services/hipchat_service.rb
+++ b/app/models/project_services/hipchat_service.rb
@@ -33,7 +33,7 @@ class HipchatService < Service
def fields
[
- { type: 'text', name: 'token', placeholder: 'Room token' },
+ { type: 'text', name: 'token', placeholder: 'Room token', required: true },
{ type: 'text', name: 'room', placeholder: 'Room name or ID' },
{ type: 'checkbox', name: 'notify' },
{ type: 'select', name: 'color', choices: %w(yellow red green purple gray random) },
@@ -41,7 +41,7 @@ class HipchatService < Service
placeholder: 'Leave blank for default (v2)' },
{ type: 'text', name: 'server',
placeholder: 'Leave blank for default. https://hipchat.example.com' },
- { type: 'checkbox', name: 'notify_only_broken_pipelines' },
+ { type: 'checkbox', name: 'notify_only_broken_pipelines' }
]
end
diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb
index c62bb4fa120..19357f90810 100644
--- a/app/models/project_services/irker_service.rb
+++ b/app/models/project_services/irker_service.rb
@@ -49,7 +49,7 @@ class IrkerService < Service
help: 'A default IRC URI to prepend before each recipient (optional)',
placeholder: 'irc://irc.network.net:6697/' },
{ type: 'textarea', name: 'recipients',
- placeholder: 'Recipients/channels separated by whitespaces',
+ placeholder: 'Recipients/channels separated by whitespaces', required: true,
help: 'Recipients have to be specified with a full URI: '\
'irc[s]://irc.network.net[:port]/#channel. Special cases: if '\
'you want the channel to be a nickname instead, append ",isnick" to ' \
@@ -58,7 +58,7 @@ class IrkerService < Service
' want to use a password, you have to omit the "#" on the channel). If you ' \
' specify a default IRC URI to prepend before each recipient, you can just ' \
' give a channel name.' },
- { type: 'checkbox', name: 'colorize_messages' },
+ { type: 'checkbox', name: 'colorize_messages' }
]
end
diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb
index 50435b67eda..ff138b9066d 100644
--- a/app/models/project_services/issue_tracker_service.rb
+++ b/app/models/project_services/issue_tracker_service.rb
@@ -32,9 +32,9 @@ class IssueTrackerService < Service
def fields
[
{ type: 'text', name: 'description', placeholder: description },
- { type: 'text', name: 'project_url', placeholder: 'Project url' },
- { type: 'text', name: 'issues_url', placeholder: 'Issue url' },
- { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url' }
+ { type: 'text', name: 'project_url', placeholder: 'Project url', required: true },
+ { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true },
+ { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url', required: true }
]
end
@@ -76,7 +76,7 @@ class IssueTrackerService < Service
message = "#{self.type} received response #{response.code} when attempting to connect to #{self.project_url}"
result = true
end
- rescue HTTParty::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED => error
+ rescue HTTParty::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED, OpenSSL::SSL::SSLError => error
message = "#{self.type} had an error when trying to connect to #{self.project_url}: #{error.message}"
end
Rails.logger.info(message)
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index 97e997d3899..2450fb43212 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -2,9 +2,10 @@ class JiraService < IssueTrackerService
include Gitlab::Routing.url_helpers
validates :url, url: true, presence: true, if: :activated?
+ validates :api_url, url: true, allow_blank: true
validates :project_key, presence: true, if: :activated?
- prop_accessor :username, :password, :url, :project_key,
+ prop_accessor :username, :password, :url, :api_url, :project_key,
:jira_issue_transition_id, :title, :description
before_update :reset_password
@@ -25,20 +26,18 @@ class JiraService < IssueTrackerService
super do
self.properties = {
title: issues_tracker['title'],
- url: issues_tracker['url']
+ url: issues_tracker['url'],
+ api_url: issues_tracker['api_url']
}
end
end
def reset_password
- # don't reset the password if a new one is provided
- if url_changed? && !password_touched?
- self.password = nil
- end
+ self.password = nil if reset_password?
end
def options
- url = URI.parse(self.url)
+ url = URI.parse(client_url)
{
username: self.username,
@@ -87,10 +86,11 @@ class JiraService < IssueTrackerService
def fields
[
- { type: 'text', name: 'url', title: 'URL', placeholder: 'https://jira.example.com' },
- { type: 'text', name: 'project_key', placeholder: 'Project Key' },
- { type: 'text', name: 'username', placeholder: '' },
- { type: 'password', name: 'password', placeholder: '' },
+ { type: 'text', name: 'url', title: 'Web URL', placeholder: 'https://jira.example.com', required: true },
+ { type: 'text', name: 'api_url', title: 'JIRA API URL', placeholder: 'If different from Web URL' },
+ { type: 'text', name: 'project_key', placeholder: 'Project Key', required: true },
+ { type: 'text', name: 'username', placeholder: '', required: true },
+ { type: 'password', name: 'password', placeholder: '', required: true },
{ type: 'text', name: 'jira_issue_transition_id', placeholder: '' }
]
end
@@ -149,7 +149,7 @@ class JiraService < IssueTrackerService
data = {
user: {
name: author.name,
- url: resource_url(user_path(author)),
+ url: resource_url(user_path(author))
},
project: {
name: self.project.path_with_namespace,
@@ -175,10 +175,6 @@ class JiraService < IssueTrackerService
{ success: result.present?, result: result }
end
- def can_test?
- username.present? && password.present?
- end
-
# JIRA does not need test data.
# We are requesting the project that belongs to the project key.
def test_data(user = nil, project = nil)
@@ -186,7 +182,7 @@ class JiraService < IssueTrackerService
end
def test_settings
- return unless url.present?
+ return unless client_url.present?
# Test settings by getting the project
jira_request { jira_project.present? }
end
@@ -236,20 +232,29 @@ class JiraService < IssueTrackerService
end
def send_message(issue, message, remote_link_props)
- return unless url.present?
+ return unless client_url.present?
jira_request do
- if issue.comments.build.save!(body: message)
- remote_link = issue.remotelink.build
+ remote_link = find_remote_link(issue, remote_link_props[:object][:url])
+ if remote_link
remote_link.save!(remote_link_props)
- result_message = "#{self.class.name} SUCCESS: Successfully posted to #{url}."
+ elsif issue.comments.build.save!(body: message)
+ new_remote_link = issue.remotelink.build
+ new_remote_link.save!(remote_link_props)
end
+ result_message = "#{self.class.name} SUCCESS: Successfully posted to #{client_url}."
Rails.logger.info(result_message)
result_message
end
end
+ def find_remote_link(issue, url)
+ links = jira_request { issue.remotelink.all }
+
+ links.find { |link| link.object["url"] == url }
+ end
+
def build_remote_link_props(url:, title:, resolved: false)
status = {
resolved: resolved
@@ -294,8 +299,21 @@ class JiraService < IssueTrackerService
def jira_request
yield
- rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError => e
- Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}"
+ rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => e
+ Rails.logger.info "#{self.class.name} Send message ERROR: #{client_url} - #{e.message}"
nil
end
+
+ def client_url
+ api_url.present? ? api_url : url
+ end
+
+ def reset_password?
+ # don't reset the password if a new one is provided
+ return false if password_touched?
+ return true if api_url_changed?
+ return false if api_url.present?
+
+ url_changed?
+ end
end
diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb
index 9c56518c991..8977a7cdafe 100644
--- a/app/models/project_services/kubernetes_service.rb
+++ b/app/models/project_services/kubernetes_service.rb
@@ -73,10 +73,18 @@ class KubernetesService < DeploymentService
{ type: 'textarea',
name: 'ca_pem',
title: 'Custom CA bundle',
- placeholder: 'Certificate Authority bundle (PEM format)' },
+ placeholder: 'Certificate Authority bundle (PEM format)' }
]
end
+ def actual_namespace
+ if namespace.present?
+ namespace
+ else
+ default_namespace
+ end
+ end
+
# Check we can connect to the Kubernetes API
def test(*args)
kubeclient = build_kubeclient!
@@ -91,7 +99,7 @@ class KubernetesService < DeploymentService
variables = [
{ key: 'KUBE_URL', value: api_url, public: true },
{ key: 'KUBE_TOKEN', value: token, public: false },
- { key: 'KUBE_NAMESPACE', value: namespace_variable, public: true }
+ { key: 'KUBE_NAMESPACE', value: actual_namespace, public: true }
]
if ca_pem.present?
@@ -110,7 +118,7 @@ class KubernetesService < DeploymentService
with_reactive_cache do |data|
pods = data.fetch(:pods, nil)
filter_pods(pods, app: environment.slug).
- flat_map { |pod| terminals_for_pod(api_url, namespace, pod) }.
+ flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) }.
each { |terminal| add_terminal_auth(terminal, terminal_auth) }
end
end
@@ -124,7 +132,7 @@ class KubernetesService < DeploymentService
# Store as hashes, rather than as third-party types
pods = begin
- kubeclient.get_pods(namespace: namespace).as_json
+ kubeclient.get_pods(namespace: actual_namespace).as_json
rescue KubeException => err
raise err unless err.error_code == 404
[]
@@ -142,20 +150,12 @@ class KubernetesService < DeploymentService
default_namespace || TEMPLATE_PLACEHOLDER
end
- def namespace_variable
- if namespace.present?
- namespace
- else
- default_namespace
- end
- end
-
def default_namespace
"#{project.path}-#{project.id}" if project.present?
end
def build_kubeclient!(api_path: 'api', api_version: 'v1')
- raise "Incomplete settings" unless api_url && namespace && token
+ raise "Incomplete settings" unless api_url && actual_namespace && token
::Kubeclient::Client.new(
join_api_url(api_path),
diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb
index 9b218fd81b4..2facff53e26 100644
--- a/app/models/project_services/microsoft_teams_service.rb
+++ b/app/models/project_services/microsoft_teams_service.rb
@@ -35,7 +35,7 @@ class MicrosoftTeamsService < ChatNotificationService
[
{ type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
- { type: 'checkbox', name: 'notify_only_default_branch' },
+ { type: 'checkbox', name: 'notify_only_default_branch' }
]
end
diff --git a/app/models/project_services/mock_ci_service.rb b/app/models/project_services/mock_ci_service.rb
index a8d581a1f67..72ddf9a4be3 100644
--- a/app/models/project_services/mock_ci_service.rb
+++ b/app/models/project_services/mock_ci_service.rb
@@ -21,7 +21,8 @@ class MockCiService < CiService
[
{ type: 'text',
name: 'mock_service_url',
- placeholder: 'http://localhost:4004' },
+ placeholder: 'http://localhost:4004',
+ required: true }
]
end
@@ -79,4 +80,8 @@ class MockCiService < CiService
:error
end
end
+
+ def can_test?
+ false
+ end
end
diff --git a/app/models/project_services/mock_monitoring_service.rb b/app/models/project_services/mock_monitoring_service.rb
index dd04e04e198..ed0318c6b27 100644
--- a/app/models/project_services/mock_monitoring_service.rb
+++ b/app/models/project_services/mock_monitoring_service.rb
@@ -14,4 +14,8 @@ class MockMonitoringService < MonitoringService
def metrics(environment)
JSON.parse(File.read(Rails.root + 'spec/fixtures/metrics.json'))
end
+
+ def can_test?
+ false
+ end
end
diff --git a/app/models/project_services/monitoring_service.rb b/app/models/project_services/monitoring_service.rb
index 59776552540..ee9cd78327a 100644
--- a/app/models/project_services/monitoring_service.rb
+++ b/app/models/project_services/monitoring_service.rb
@@ -9,8 +9,11 @@ class MonitoringService < Service
%w()
end
- # Environments have a number of metrics
- def metrics(environment, timeframe_start: nil, timeframe_end: nil)
+ def environment_metrics(environment)
+ raise NotImplementedError
+ end
+
+ def deployment_metrics(deployment)
raise NotImplementedError
end
end
diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb
index ac617f409d9..9d37184be2c 100644
--- a/app/models/project_services/pipelines_email_service.rb
+++ b/app/models/project_services/pipelines_email_service.rb
@@ -53,9 +53,10 @@ class PipelinesEmailService < Service
[
{ type: 'textarea',
name: 'recipients',
- placeholder: 'Emails separated by comma' },
+ placeholder: 'Emails separated by comma',
+ required: true },
{ type: 'checkbox',
- name: 'notify_only_broken_pipelines' },
+ name: 'notify_only_broken_pipelines' }
]
end
diff --git a/app/models/project_services/pivotaltracker_service.rb b/app/models/project_services/pivotaltracker_service.rb
index d86f4f6f448..f9dfa2e91c3 100644
--- a/app/models/project_services/pivotaltracker_service.rb
+++ b/app/models/project_services/pivotaltracker_service.rb
@@ -23,7 +23,8 @@ class PivotaltrackerService < Service
{
type: 'text',
name: 'token',
- placeholder: 'Pivotal Tracker API token.'
+ placeholder: 'Pivotal Tracker API token.',
+ required: true
},
{
type: 'text',
diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb
index 6a4479c4dbc..110b8bc209b 100644
--- a/app/models/project_services/prometheus_service.rb
+++ b/app/models/project_services/prometheus_service.rb
@@ -49,7 +49,8 @@ class PrometheusService < MonitoringService
type: 'text',
name: 'api_url',
title: 'API URL',
- placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/'
+ placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/',
+ required: true
}
]
end
@@ -63,45 +64,31 @@ class PrometheusService < MonitoringService
{ success: false, result: err }
end
- def metrics(environment, timeframe_start: nil, timeframe_end: nil)
- with_reactive_cache(environment.slug, timeframe_start, timeframe_end) do |data|
- data
- end
+ def environment_metrics(environment)
+ with_reactive_cache(Gitlab::Prometheus::Queries::EnvironmentQuery.name, environment.id, &:itself)
+ end
+
+ def deployment_metrics(deployment)
+ metrics = with_reactive_cache(Gitlab::Prometheus::Queries::DeploymentQuery.name, deployment.id, &:itself)
+ metrics&.merge(deployment_time: created_at.to_i) || {}
end
# Cache metrics for specific environment
- def calculate_reactive_cache(environment_slug, timeframe_start, timeframe_end)
+ def calculate_reactive_cache(query_class_name, *args)
return unless active? && project && !project.pending_delete?
- timeframe_start = Time.parse(timeframe_start) if timeframe_start
- timeframe_end = Time.parse(timeframe_end) if timeframe_end
-
- timeframe_start ||= 8.hours.ago
- timeframe_end ||= Time.now
-
- memory_query = %{(sum(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}) / count(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"})) /1024/1024}
- cpu_query = %{sum(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) / count(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}) * 100}
+ metrics = Kernel.const_get(query_class_name).new(client).query(*args)
{
success: true,
- metrics: {
- # Average Memory used in MB
- memory_values: client.query_range(memory_query, start: timeframe_start, stop: timeframe_end),
- memory_current: client.query(memory_query, time: timeframe_end),
- memory_previous: client.query(memory_query, time: timeframe_start),
- # Average CPU Utilization
- cpu_values: client.query_range(cpu_query, start: timeframe_start, stop: timeframe_end),
- cpu_current: client.query(cpu_query, time: timeframe_end),
- cpu_previous: client.query(cpu_query, time: timeframe_start)
- },
+ metrics: metrics,
last_update: Time.now.utc
}
-
rescue Gitlab::PrometheusError => err
{ success: false, result: err.message }
end
def client
- @prometheus ||= Gitlab::Prometheus.new(api_url: api_url)
+ @prometheus ||= Gitlab::PrometheusClient.new(api_url: api_url)
end
end
diff --git a/app/models/project_services/pushover_service.rb b/app/models/project_services/pushover_service.rb
index 3e618a8dbf1..aa7bd4c3c84 100644
--- a/app/models/project_services/pushover_service.rb
+++ b/app/models/project_services/pushover_service.rb
@@ -19,10 +19,10 @@ class PushoverService < Service
def fields
[
- { type: 'text', name: 'api_key', placeholder: 'Your application key' },
- { type: 'text', name: 'user_key', placeholder: 'Your user key' },
+ { type: 'text', name: 'api_key', placeholder: 'Your application key', required: true },
+ { type: 'text', name: 'user_key', placeholder: 'Your user key', required: true },
{ type: 'text', name: 'device', placeholder: 'Leave blank for all active devices' },
- { type: 'select', name: 'priority', choices:
+ { type: 'select', name: 'priority', required: true, choices:
[
['Lowest Priority', -2],
['Low Priority', -1],
@@ -55,7 +55,7 @@ class PushoverService < Service
['Pushover Echo (long)', 'echo'],
['Up Down (long)', 'updown'],
['None (silent)', 'none']
- ] },
+ ] }
]
end
diff --git a/app/models/project_services/teamcity_service.rb b/app/models/project_services/teamcity_service.rb
index cbaffb8ce48..cbe137452bd 100644
--- a/app/models/project_services/teamcity_service.rb
+++ b/app/models/project_services/teamcity_service.rb
@@ -50,12 +50,12 @@ class TeamcityService < CiService
def fields
[
{ type: 'text', name: 'teamcity_url',
- placeholder: 'TeamCity root URL like https://teamcity.example.com' },
+ placeholder: 'TeamCity root URL like https://teamcity.example.com', required: true },
{ type: 'text', name: 'build_type',
- placeholder: 'Build configuration ID' },
+ placeholder: 'Build configuration ID', required: true },
{ type: 'text', name: 'username',
placeholder: 'A user with permissions to trigger a manual build' },
- { type: 'password', name: 'password' },
+ { type: 'password', name: 'password' }
]
end
@@ -78,7 +78,7 @@ class TeamcityService < CiService
auth = {
username: username,
- password: password,
+ password: password
}
branch = Gitlab::Git.ref_name(data[:ref])
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index 543b9b293e0..e1cc56551ba 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -167,7 +167,7 @@ class ProjectTeam
access = RequestStore.store[key]
end
- # Lookup only the IDs we need
+ # Look up only the IDs we need
user_ids = user_ids - access.keys
return access if user_ids.empty?
@@ -178,6 +178,13 @@ class ProjectTeam
maximum(:access_level)
access.merge!(users_access)
+
+ missing_user_ids = user_ids - users_access.keys
+
+ missing_user_ids.each do |user_id|
+ access[user_id] = Gitlab::Access::NO_ACCESS
+ end
+
access
end
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index 189c106b70b..f38fbda7839 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -42,11 +42,8 @@ class ProjectWiki
url_to_repo
end
- def http_url_to_repo(user = nil)
- url = "#{Gitlab.config.gitlab.url}/#{path_with_namespace}.git"
- credentials = Gitlab::UrlSanitizer.http_credentials_for_user(user)
-
- Gitlab::UrlSanitizer.new(url, credentials: credentials).full_url
+ def http_url_to_repo
+ "#{Gitlab.config.gitlab.url}/#{path_with_namespace}.git"
end
def wiki_base_path
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 28b7d5ad072..5f0d0802ac9 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -2,14 +2,7 @@ class ProtectedBranch < ActiveRecord::Base
include Gitlab::ShellAdapter
include ProtectedRef
- has_many :merge_access_levels, dependent: :destroy
- has_many :push_access_levels, dependent: :destroy
-
- validates :merge_access_levels, length: { is: 1, message: "are restricted to a single instance per protected branch." }
- validates :push_access_levels, length: { is: 1, message: "are restricted to a single instance per protected branch." }
-
- accepts_nested_attributes_for :push_access_levels
- accepts_nested_attributes_for :merge_access_levels
+ protected_ref_access_levels :merge, :push
# Check if branch name is marked as protected in the system
def self.protected?(project, ref_name)
diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb
index 771e3376613..e8d35ac326f 100644
--- a/app/models/protected_branch/merge_access_level.rb
+++ b/app/models/protected_branch/merge_access_level.rb
@@ -1,13 +1,3 @@
class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base
include ProtectedBranchAccess
-
- validates :access_level, presence: true, inclusion: { in: [Gitlab::Access::MASTER,
- Gitlab::Access::DEVELOPER] }
-
- def self.human_access_levels
- {
- Gitlab::Access::MASTER => "Masters",
- Gitlab::Access::DEVELOPER => "Developers + Masters"
- }.with_indifferent_access
- end
end
diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb
index 14610cb42b7..7a2e9e5ec5d 100644
--- a/app/models/protected_branch/push_access_level.rb
+++ b/app/models/protected_branch/push_access_level.rb
@@ -1,21 +1,3 @@
class ProtectedBranch::PushAccessLevel < ActiveRecord::Base
include ProtectedBranchAccess
-
- validates :access_level, presence: true, inclusion: { in: [Gitlab::Access::MASTER,
- Gitlab::Access::DEVELOPER,
- Gitlab::Access::NO_ACCESS] }
-
- def self.human_access_levels
- {
- Gitlab::Access::MASTER => "Masters",
- Gitlab::Access::DEVELOPER => "Developers + Masters",
- Gitlab::Access::NO_ACCESS => "No one"
- }.with_indifferent_access
- end
-
- def check_access(user)
- return false if access_level == Gitlab::Access::NO_ACCESS
-
- super
- end
end
diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb
index 83964095516..f38109c0e52 100644
--- a/app/models/protected_tag.rb
+++ b/app/models/protected_tag.rb
@@ -2,11 +2,7 @@ class ProtectedTag < ActiveRecord::Base
include Gitlab::ShellAdapter
include ProtectedRef
- has_many :create_access_levels, dependent: :destroy
-
- validates :create_access_levels, length: { is: 1, message: "are restricted to a single instance per protected tag." }
-
- accepts_nested_attributes_for :create_access_levels
+ protected_ref_access_levels :create
def self.protected?(project, ref_name)
self.matching(ref_name, protected_refs: project.protected_tags).present?
diff --git a/app/models/readme_blob.rb b/app/models/readme_blob.rb
new file mode 100644
index 00000000000..1863a08f1de
--- /dev/null
+++ b/app/models/readme_blob.rb
@@ -0,0 +1,13 @@
+class ReadmeBlob < SimpleDelegator
+ attr_reader :repository
+
+ def initialize(blob, repository)
+ @repository = repository
+
+ super(blob)
+ end
+
+ def rendered_markup
+ repository.rendered_readme
+ end
+end
diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb
index 99812bcde53..964175ddab8 100644
--- a/app/models/redirect_route.rb
+++ b/app/models/redirect_route.rb
@@ -1,5 +1,5 @@
class RedirectRoute < ActiveRecord::Base
- belongs_to :source, polymorphic: true
+ belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :source, presence: true
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 00b11ecef9f..00a0b407512 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -30,7 +30,7 @@ class Repository
METHOD_CACHES_FOR_FILE_TYPES = {
readme: :rendered_readme,
changelog: :changelog,
- license: %i(license_blob license_key),
+ license: %i(license_blob license_key license),
contributing: :contribution_guide,
gitignore: :gitignore,
koding: :koding_yml,
@@ -42,13 +42,13 @@ class Repository
# variable.
#
# This only works for methods that do not take any arguments.
- def self.cache_method(name, fallback: nil)
+ def self.cache_method(name, fallback: nil, memoize_only: false)
original = :"_uncached_#{name}"
alias_method(original, name)
define_method(name) do
- cache_method_output(name, fallback: fallback) { __send__(original) }
+ cache_method_output(name, fallback: fallback, memoize_only: memoize_only) { __send__(original) }
end
end
@@ -517,8 +517,8 @@ class Repository
cache_method :avatar
def readme
- if head = tree(:head)
- head.readme
+ if readme = tree(:head)&.readme
+ ReadmeBlob.new(readme, self)
end
end
@@ -549,6 +549,13 @@ class Repository
end
cache_method :license_key
+ def license
+ return unless license_key
+
+ Licensee::License.new(license_key)
+ end
+ cache_method :license, memoize_only: true
+
def gitignore
file_on_head(:gitignore)
end
@@ -642,22 +649,8 @@ class Repository
"#{name}-#{highest_branch_id + 1}"
end
- # Remove archives older than 2 hours
def branches_sorted_by(value)
- case value
- when 'name'
- branches.sort_by(&:name)
- when 'updated_desc'
- branches.sort do |a, b|
- commit(b.dereferenced_target).committed_date <=> commit(a.dereferenced_target).committed_date
- end
- when 'updated_asc'
- branches.sort do |a, b|
- commit(a.dereferenced_target).committed_date <=> commit(b.dereferenced_target).committed_date
- end
- else
- branches
- end
+ raw_repository.local_branches(sort_by: value)
end
def tags_sorted_by(value)
@@ -833,7 +826,7 @@ class Repository
actual_options = options.merge(
parents: [our_commit, their_commit],
- tree: merge_index.write_tree(rugged),
+ tree: merge_index.write_tree(rugged)
)
commit_id = create_commit(actual_options)
@@ -953,6 +946,8 @@ class Repository
end
def is_ancestor?(ancestor_id, descendant_id)
+ return false if ancestor_id.nil? || descendant_id.nil?
+
Gitlab::GitalyClient.migrate(:is_ancestor) do |is_enabled|
if is_enabled
raw_repository.is_ancestor?(ancestor_id, descendant_id)
@@ -1061,14 +1056,20 @@ class Repository
#
# key - The name of the key to cache the data in.
# fallback - A value to fall back to in the event of a Git error.
- def cache_method_output(key, fallback: nil, &block)
+ def cache_method_output(key, fallback: nil, memoize_only: false, &block)
ivar = cache_instance_variable_name(key)
if instance_variable_defined?(ivar)
instance_variable_get(ivar)
else
begin
- instance_variable_set(ivar, cache.fetch(key, &block))
+ value =
+ if memoize_only
+ yield
+ else
+ cache.fetch(key, &block)
+ end
+ instance_variable_set(ivar, value)
rescue Rugged::ReferenceError, Gitlab::Git::Repository::NoRepository
# if e.g. HEAD or the entire repository doesn't exist we want to
# gracefully handle this and not cache anything.
@@ -1083,8 +1084,8 @@ class Repository
def file_on_head(type)
if head = tree(:head)
- head.blobs.find do |file|
- Gitlab::FileDetector.type_of(file.name) == type
+ head.blobs.find do |blob|
+ Gitlab::FileDetector.type_of(blob.path) == type
end
end
end
diff --git a/app/models/route.rb b/app/models/route.rb
index 12a7fa3d01b..97e8a6ad9e9 100644
--- a/app/models/route.rb
+++ b/app/models/route.rb
@@ -1,5 +1,5 @@
class Route < ActiveRecord::Base
- belongs_to :source, polymorphic: true
+ belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :source, presence: true
@@ -35,7 +35,7 @@ class Route < ActiveRecord::Base
old_path = route.path
# Callbacks must be run manually
- route.update_columns(attributes)
+ route.update_columns(attributes.merge(updated_at: Time.now))
# We are not calling route.delete_conflicting_redirects here, in hopes
# of avoiding deadlocks. The parent (self, in this method) already
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index bfaf0eb2fae..edde7bedbab 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -1,8 +1,8 @@
class SentNotification < ActiveRecord::Base
- serialize :position, Gitlab::Diff::Position
+ serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiverecordSerialize
belongs_to :project
- belongs_to :noteable, polymorphic: true
+ belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :recipient, class_name: "User"
validates :project, :recipient, presence: true
@@ -39,7 +39,7 @@ class SentNotification < ActiveRecord::Base
noteable_type: noteable.class.name,
noteable_id: noteable_id,
- commit_id: commit_id,
+ commit_id: commit_id
)
create(attrs)
diff --git a/app/models/service.rb b/app/models/service.rb
index c71a7d169ec..6a0b0a5c522 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -2,7 +2,7 @@
# and implement a set of methods
class Service < ActiveRecord::Base
include Sortable
- serialize :properties, JSON
+ serialize :properties, JSON # rubocop:disable Cop/ActiverecordSerialize
default_value_for :active, false
default_value_for :push_events, true
@@ -12,7 +12,7 @@ class Service < ActiveRecord::Base
default_value_for :merge_requests_events, true
default_value_for :tag_push_events, true
default_value_for :note_events, true
- default_value_for :build_events, true
+ default_value_for :job_events, true
default_value_for :pipeline_events, true
default_value_for :wiki_page_events, true
@@ -40,7 +40,7 @@ class Service < ActiveRecord::Base
scope :confidential_issue_hooks, -> { where(confidential_issues_events: true, active: true) }
scope :merge_request_hooks, -> { where(merge_requests_events: true, active: true) }
scope :note_hooks, -> { where(note_events: true, active: true) }
- scope :build_hooks, -> { where(build_events: true, active: true) }
+ scope :job_hooks, -> { where(job_events: true, active: true) }
scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) }
scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) }
scope :external_issue_trackers, -> { issue_trackers.active.without_defaults }
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 6c3358685fe..54014df43b0 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -11,6 +11,7 @@ class Snippet < ActiveRecord::Base
include Editable
cache_markdown_field :title, pipeline: :single_line
+ cache_markdown_field :description
cache_markdown_field :content
# Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with snippets.
diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb
index dd21ee15c6c..56a115d1db4 100644
--- a/app/models/spam_log.rb
+++ b/app/models/spam_log.rb
@@ -4,8 +4,7 @@ class SpamLog < ActiveRecord::Base
validates :user, presence: true
def remove_user(deleted_by:)
- user.block
- DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true, hard_delete: true)
+ user.delete_async(deleted_by: deleted_by, params: { hard_delete: true })
end
def text
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
index 17869c8bac2..2f0c9640744 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -1,7 +1,7 @@
class Subscription < ActiveRecord::Base
belongs_to :user
belongs_to :project
- belongs_to :subscribable, polymorphic: true
+ belongs_to :subscribable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :user, :subscribable, presence: true
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index b44f4fe000c..414c95f7705 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -2,6 +2,7 @@ class SystemNoteMetadata < ActiveRecord::Base
ICON_TYPES = %w[
commit description merge confidential visible label assignee cross_reference
title time_tracking branch milestone discussion task moved opened closed merged
+ outdated
].freeze
validates :note, presence: true
diff --git a/app/models/todo.rb b/app/models/todo.rb
index b011001b235..696d139af74 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -22,7 +22,7 @@ class Todo < ActiveRecord::Base
belongs_to :author, class_name: "User"
belongs_to :note
belongs_to :project
- belongs_to :target, polymorphic: true, touch: true
+ belongs_to :target, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :user
delegate :name, :email, to: :author, prefix: true, allow_nil: true
diff --git a/app/models/tree.rb b/app/models/tree.rb
index fe148b0ec65..c89b8eca9be 100644
--- a/app/models/tree.rb
+++ b/app/models/tree.rb
@@ -40,10 +40,7 @@ class Tree
readme_path = path == '/' ? readme_tree.name : File.join(path, readme_tree.name)
- git_repo = repository.raw_repository
- @readme = Gitlab::Git::Blob.find(git_repo, sha, readme_path)
- @readme.load_all_data!(git_repo)
- @readme
+ @readme = repository.blob_at(sha, readme_path)
end
def trees
diff --git a/app/models/upload.rb b/app/models/upload.rb
index 13987931b05..f194d7bdb80 100644
--- a/app/models/upload.rb
+++ b/app/models/upload.rb
@@ -2,7 +2,7 @@ class Upload < ActiveRecord::Base
# Upper limit for foreground checksum processing
CHECKSUM_THRESHOLD = 100.megabytes
- belongs_to :model, polymorphic: true
+ belongs_to :model, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :size, presence: true
validates :path, presence: true
diff --git a/app/models/user.rb b/app/models/user.rb
index efd53810ec3..5d128e4b390 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -5,6 +5,7 @@ class User < ActiveRecord::Base
include Gitlab::ConfigHelper
include Gitlab::CurrentSettings
+ include Avatarable
include Referable
include Sortable
include CaseSensitivity
@@ -17,6 +18,7 @@ class User < ActiveRecord::Base
add_authentication_token_field :authentication_token
add_authentication_token_field :incoming_email_token
+ add_authentication_token_field :rss_token
default_value_for :admin, false
default_value_for(:external) { current_application_settings.user_default_external }
@@ -38,11 +40,22 @@ class User < ActiveRecord::Base
otp_secret_encryption_key: Gitlab::Application.secrets.otp_key_base
devise :two_factor_backupable, otp_number_of_backup_codes: 10
- serialize :otp_backup_codes, JSON
+ serialize :otp_backup_codes, JSON # rubocop:disable Cop/ActiverecordSerialize
devise :lockable, :recoverable, :rememberable, :trackable,
:validatable, :omniauthable, :confirmable, :registerable
+ # Override Devise::Models::Trackable#update_tracked_fields!
+ # to limit database writes to at most once every hour
+ def update_tracked_fields!(request)
+ update_tracked_fields(request)
+
+ lease = Gitlab::ExclusiveLease.new("user_update_tracked_fields:#{id}", timeout: 1.hour.to_i)
+ return unless lease.try_obtain
+
+ save(validate: false)
+ end
+
attr_accessor :force_random_password
# Virtual attribute for authenticating by either username or email
@@ -53,7 +66,7 @@ class User < ActiveRecord::Base
#
# Namespace for personal projects
- has_one :namespace, -> { where type: nil }, dependent: :destroy, foreign_key: :owner_id
+ has_one :namespace, -> { where type: nil }, dependent: :destroy, foreign_key: :owner_id, autosave: true
# Profile
has_many :keys, -> do
@@ -88,6 +101,7 @@ class User < ActiveRecord::Base
has_many :snippets, dependent: :destroy, foreign_key: :author_id
has_many :notes, dependent: :destroy, foreign_key: :author_id
+ has_many :issues, dependent: :destroy, foreign_key: :author_id
has_many :merge_requests, dependent: :destroy, foreign_key: :author_id
has_many :events, dependent: :destroy, foreign_key: :author_id
has_many :subscriptions, dependent: :destroy
@@ -107,11 +121,6 @@ class User < ActiveRecord::Base
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.
- has_many :issues, dependent: :restrict_with_exception, foreign_key: :author_id
-
#
# Validations
#
@@ -157,8 +166,13 @@ class User < ActiveRecord::Base
enum dashboard: [:projects, :stars, :project_activity, :starred_project_activity, :groups, :todos]
# User's Project preference
- # Note: When adding an option, it MUST go on the end of the array.
- enum project_view: [:readme, :activity, :files]
+ #
+ # Note: When adding an option, it MUST go on the end of the hash with a
+ # number higher than the current max. We cannot move options and/or change
+ # their numbers.
+ #
+ # We skip 0 because this was used by an option that has since been removed.
+ enum project_view: { activity: 1, files: 2 }
alias_attribute :private_token, :authentication_token
@@ -351,8 +365,9 @@ class User < ActiveRecord::Base
# Pattern used to extract `@user` user references from text
def reference_pattern
%r{
+ (?<!\w)
#{Regexp.escape(reference_prefix)}
- (?<user>#{Gitlab::Regex::FULL_NAMESPACE_REGEX_STR})
+ (?<user>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})
}x
end
@@ -539,12 +554,6 @@ class User < ActiveRecord::Base
authorized_projects(Gitlab::Access::REPORTER).where(id: projects)
end
- def viewable_starred_projects
- starred_projects.where("projects.visibility_level IN (?) OR projects.id IN (?)",
- [Project::PUBLIC, Project::INTERNAL],
- authorized_projects.select(:project_id))
- end
-
def owned_projects
@owned_projects ||=
Project.where('namespace_id IN (?) OR namespace_id = ?',
@@ -765,12 +774,10 @@ class User < ActiveRecord::Base
email.start_with?('temp-email-for-oauth')
end
- def avatar_url(size = nil, scale = 2)
- if self[:avatar].present?
- [gitlab_config.url, avatar.url].join
- else
- GravatarService.new.execute(email, size, scale)
- end
+ def avatar_url(size: nil, scale: 2, **args)
+ # We use avatar_path instead of overriding avatar_url because of carrierwave.
+ # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864
+ avatar_path(args) || GravatarService.new.execute(email, size, scale, username: username)
end
def all_emails
@@ -802,6 +809,11 @@ class User < ActiveRecord::Base
system_hook_service.execute_hooks_for(self, :destroy)
end
+ def delete_async(deleted_by:, params: {})
+ block if params[:hard_delete]
+ DeleteUserWorker.perform_async(deleted_by.id, id, params)
+ end
+
def notification_service
NotificationService.new
end
@@ -895,13 +907,13 @@ class User < ActiveRecord::Base
end
def assigned_open_merge_requests_count(force: false)
- Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force) do
+ Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force, expires_in: 20.minutes) do
MergeRequestsFinder.new(self, assignee_id: self.id, state: 'opened').execute.count
end
end
def assigned_open_issues_count(force: false)
- Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force) do
+ Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: 20.minutes) do
IssuesFinder.new(self, assignee_id: self.id, state: 'opened').execute.count
end
end
@@ -912,10 +924,18 @@ class User < ActiveRecord::Base
end
def invalidate_cache_counts
- Rails.cache.delete(['users', id, 'assigned_open_merge_requests_count'])
+ invalidate_issue_cache_counts
+ invalidate_merge_request_cache_counts
+ end
+
+ def invalidate_issue_cache_counts
Rails.cache.delete(['users', id, 'assigned_open_issues_count'])
end
+ def invalidate_merge_request_cache_counts
+ Rails.cache.delete(['users', id, 'assigned_open_merge_requests_count'])
+ end
+
def todos_done_count(force: false)
Rails.cache.fetch(['users', id, 'todos_done_count'], force: force) do
TodosFinder.new(self, state: :done).execute.count
@@ -973,6 +993,13 @@ class User < ActiveRecord::Base
save
end
+ # each existing user needs to have an `rss_token`.
+ # we do this on read since migrating all existing users is not a feasible
+ # solution.
+ def rss_token
+ ensure_rss_token!
+ end
+
protected
# override, from Devise::Validatable
diff --git a/app/models/user_agent_detail.rb b/app/models/user_agent_detail.rb
index 0949c6ef083..2d05fdd3e54 100644
--- a/app/models/user_agent_detail.rb
+++ b/app/models/user_agent_detail.rb
@@ -1,5 +1,5 @@
class UserAgentDetail < ActiveRecord::Base
- belongs_to :subject, polymorphic: true
+ belongs_to :subject, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :user_agent, :ip_address, :subject_id, :subject_type, presence: true
diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb
index d4af4490608..2d7405dc240 100644
--- a/app/policies/ci/build_policy.rb
+++ b/app/policies/ci/build_policy.rb
@@ -23,7 +23,7 @@ module Ci
!::Gitlab::UserAccess
.new(user, project: build.project)
- .can_push_to_branch?(build.ref)
+ .can_merge_to_branch?(build.ref)
end
end
end
diff --git a/app/policies/deploy_key_policy.rb b/app/policies/deploy_key_policy.rb
new file mode 100644
index 00000000000..ebab213e6be
--- /dev/null
+++ b/app/policies/deploy_key_policy.rb
@@ -0,0 +1,11 @@
+class DeployKeyPolicy < BasePolicy
+ def rules
+ return unless @user
+
+ can! :update_deploy_key if @user.admin?
+
+ if @subject.private? && @user.project_deploy_keys.exists?(id: @subject.id)
+ can! :update_deploy_key
+ end
+ end
+end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 87398303c68..fb07298c6c2 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -4,22 +4,25 @@ class GroupPolicy < BasePolicy
return unless @user
globally_viewable = @subject.public? || (@subject.internal? && !@user.external?)
- member = @subject.users_with_parents.include?(@user)
- owner = @user.admin? || @subject.has_owner?(@user)
- master = owner || @subject.has_master?(@user)
+ access_level = @subject.max_member_access_for_user(@user)
+ owner = access_level >= GroupMember::OWNER
+ master = access_level >= GroupMember::MASTER
+ reporter = access_level >= GroupMember::REPORTER
can_read = false
can_read ||= globally_viewable
- can_read ||= member
- can_read ||= @user.admin?
+ can_read ||= access_level >= GroupMember::GUEST
can_read ||= GroupProjectsFinder.new(group: @subject, current_user: @user).execute.any?
can! :read_group if can_read
+ if reporter
+ can! :admin_label
+ end
+
# Only group masters and group owners can create new projects
if master
can! :create_projects
can! :admin_milestones
- can! :admin_label
end
# Only group owner and administrators can admin group
@@ -31,7 +34,7 @@ class GroupPolicy < BasePolicy
can! :create_subgroup if @user.can_create_group
end
- if globally_viewable && @subject.request_access_enabled && !member
+ if globally_viewable && @subject.request_access_enabled && access_level == GroupMember::NO_ACCESS
can! :request_access
end
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 8f25ac30a22..3959b895f44 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -98,7 +98,7 @@ class ProjectPolicy < BasePolicy
end
def master_access!
- can! :push_code_to_protected_branches
+ can! :delete_protected_branch
can! :update_project_snippet
can! :update_environment
can! :update_deployment
@@ -173,7 +173,7 @@ class ProjectPolicy < BasePolicy
def archived_access!
cannot! :create_merge_request
cannot! :push_code
- cannot! :push_code_to_protected_branches
+ cannot! :delete_protected_branch
cannot! :update_merge_request
cannot! :admin_merge_request
end
@@ -211,7 +211,7 @@ class ProjectPolicy < BasePolicy
unless repository_enabled
cannot! :push_code
- cannot! :push_code_to_protected_branches
+ cannot! :delete_protected_branch
cannot! :download_code
cannot! :fork_project
cannot! :read_commit_status
diff --git a/app/presenters/conversational_development_index/metric_presenter.rb b/app/presenters/conversational_development_index/metric_presenter.rb
new file mode 100644
index 00000000000..bb65ba2646b
--- /dev/null
+++ b/app/presenters/conversational_development_index/metric_presenter.rb
@@ -0,0 +1,144 @@
+module ConversationalDevelopmentIndex
+ class MetricPresenter < Gitlab::View::Presenter::Simple
+ def cards
+ [
+ Card.new(
+ metric: subject,
+ title: 'Issues',
+ description: 'created per active user',
+ feature: 'issues',
+ blog: 'https://www2.deloitte.com/content/dam/Deloitte/se/Documents/technology-media-telecommunications/deloitte-digital-collaboration.pdf'
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Comments',
+ description: 'created per active user',
+ feature: 'notes',
+ blog: 'http://conversationaldevelopment.com/why/'
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Milestones',
+ description: 'created per active user',
+ feature: 'milestones',
+ blog: 'http://conversationaldevelopment.com/shorten-cycle/',
+ docs: help_page_path('user/project/milestones/index')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Boards',
+ description: 'created per active user',
+ feature: 'boards',
+ blog: 'http://jpattonassociates.com/user-story-mapping/',
+ docs: help_page_path('user/project/issue_board')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Merge Requests',
+ description: 'per active user',
+ feature: 'merge_requests',
+ blog: 'https://8thlight.com/blog/uncle-bob/2013/02/01/The-Humble-Craftsman.html',
+ docs: help_page_path('user/project/merge_requests/index')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Pipelines',
+ description: 'created per active user',
+ feature: 'ci_pipelines',
+ blog: 'https://martinfowler.com/bliki/ContinuousDelivery.html',
+ docs: help_page_path('ci/README')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Environments',
+ description: 'created per active user',
+ feature: 'environments',
+ blog: 'https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/',
+ docs: help_page_path('ci/environments')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Deployments',
+ description: 'created per active user',
+ feature: 'deployments',
+ blog: 'https://puppet.com/blog/continuous-delivery-vs-continuous-deployment-what-s-diff'
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Monitoring',
+ description: 'fraction of all projects',
+ feature: 'projects_prometheus_active',
+ blog: 'https://prometheus.io/docs/introduction/overview/',
+ docs: help_page_path('user/project/integrations/prometheus')
+ ),
+ Card.new(
+ metric: subject,
+ title: 'Service Desk',
+ description: 'issues created per active user',
+ feature: 'service_desk_issues',
+ blog: 'http://blogs.forrester.com/kate_leggett/17-01-30-top_trends_for_customer_service_in_2017_operations_become_smarter_and_more_strategic',
+ docs: 'https://docs.gitlab.com/ee/user/project/service_desk.html'
+ )
+ ]
+ end
+
+ def idea_to_production_steps
+ [
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Idea',
+ features: %w(issues)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Issue',
+ features: %w(issues notes)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Plan',
+ features: %w(milestones boards)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Code',
+ features: %w(merge_requests)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Commit',
+ features: %w(merge_requests)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Test',
+ features: %w(ci_pipelines)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Review',
+ features: %w(ci_pipelines environments)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Staging',
+ features: %w(environments deployments)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Production',
+ features: %w(deployments)
+ ),
+ IdeaToProductionStep.new(
+ metric: subject,
+ title: 'Feedback',
+ features: %w(projects_prometheus_active service_desk_issues)
+ )
+ ]
+ end
+
+ def average_percentage_score
+ cards.sum(&:percentage_score) / cards.size.to_f
+ end
+ end
+end
diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb
index 070b0c35e36..229311eb6ee 100644
--- a/app/presenters/projects/settings/deploy_keys_presenter.rb
+++ b/app/presenters/projects/settings/deploy_keys_presenter.rb
@@ -11,7 +11,7 @@ module Projects
end
def enabled_keys
- @enabled_keys ||= project.deploy_keys
+ @enabled_keys ||= project.deploy_keys.includes(:projects)
end
def any_keys_enabled?
@@ -23,11 +23,7 @@ module Projects
end
def available_project_keys
- @available_project_keys ||= current_user.project_deploy_keys - enabled_keys
- end
-
- def any_available_project_keys_enabled?
- available_project_keys.any?
+ @available_project_keys ||= current_user.project_deploy_keys.includes(:projects) - enabled_keys
end
def key_available?(deploy_key)
@@ -37,17 +33,13 @@ module Projects
def available_public_keys
return @available_public_keys if defined?(@available_public_keys)
- @available_public_keys ||= DeployKey.are_public - enabled_keys
+ @available_public_keys ||= DeployKey.are_public.includes(:projects) - enabled_keys
# Public keys that are already used by another accessible project are already
# in @available_project_keys.
@available_public_keys -= available_project_keys
end
- def any_available_public_keys_enabled?
- available_public_keys.any?
- end
-
def as_json
serializer = DeployKeySerializer.new
opts = { user: current_user }
diff --git a/app/serializers/analytics_build_entity.rb b/app/serializers/analytics_build_entity.rb
index a0db5b8f0f4..ad7ad020b03 100644
--- a/app/serializers/analytics_build_entity.rb
+++ b/app/serializers/analytics_build_entity.rb
@@ -25,7 +25,7 @@ class AnalyticsBuildEntity < Grape::Entity
end
expose :url do |build|
- url_to(:namespace_project_build, build)
+ url_to(:namespace_project_job, build)
end
expose :commit_url do |build|
diff --git a/app/serializers/build_action_entity.rb b/app/serializers/build_action_entity.rb
index 5e99204c658..301b718d060 100644
--- a/app/serializers/build_action_entity.rb
+++ b/app/serializers/build_action_entity.rb
@@ -6,7 +6,7 @@ class BuildActionEntity < Grape::Entity
end
expose :path do |build|
- play_namespace_project_build_path(
+ play_namespace_project_job_path(
build.project.namespace,
build.project,
build)
diff --git a/app/serializers/build_artifact_entity.rb b/app/serializers/build_artifact_entity.rb
index 8b643d8e783..cb55c98f7c6 100644
--- a/app/serializers/build_artifact_entity.rb
+++ b/app/serializers/build_artifact_entity.rb
@@ -1,14 +1,39 @@
class BuildArtifactEntity < Grape::Entity
include RequestAwareEntity
- expose :name do |build|
- build.name
+ expose :name do |job|
+ job.name
end
- expose :path do |build|
- download_namespace_project_build_artifacts_path(
- build.project.namespace,
- build.project,
- build)
+ expose :artifacts_expired?, as: :expired
+ expose :artifacts_expire_at, as: :expire_at
+
+ expose :path do |job|
+ download_namespace_project_job_artifacts_path(
+ project.namespace,
+ project,
+ job)
+ end
+
+ expose :keep_path, if: -> (*) { job.has_expiring_artifacts? } do |job|
+ keep_namespace_project_job_artifacts_path(
+ project.namespace,
+ project,
+ job)
+ end
+
+ expose :browse_path do |job|
+ browse_namespace_project_job_artifacts_path(
+ project.namespace,
+ project,
+ job)
+ end
+
+ private
+
+ alias_method :job, :object
+
+ def project
+ job.project
end
end
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
new file mode 100644
index 00000000000..0063920e603
--- /dev/null
+++ b/app/serializers/build_details_entity.rb
@@ -0,0 +1,50 @@
+class BuildDetailsEntity < BuildEntity
+ expose :coverage, :erased_at, :duration
+ expose :tag_list, as: :tags
+
+ expose :user, using: UserEntity
+
+ expose :erased_by, if: -> (*) { build.erased? }, using: UserEntity
+ expose :erase_path, if: -> (*) { build.erasable? && can?(current_user, :update_build, project) } do |build|
+ erase_namespace_project_job_path(project.namespace, project, build)
+ end
+
+ expose :artifacts, using: BuildArtifactEntity
+ expose :runner, using: RunnerEntity
+ expose :pipeline, using: PipelineEntity
+
+ expose :merge_request, if: -> (*) { can?(current_user, :read_merge_request, build.merge_request) } do
+ expose :iid do |build|
+ build.merge_request.iid
+ end
+
+ expose :path do |build|
+ namespace_project_merge_request_path(project.namespace, project, build.merge_request)
+ end
+ end
+
+ expose :new_issue_path, if: -> (*) { can?(request.current_user, :create_issue, project) && build.failed? } do |build|
+ new_namespace_project_issue_path(project.namespace, project, issue: build_failed_issue_options)
+ end
+
+ expose :raw_path do |build|
+ raw_namespace_project_build_path(project.namespace, project, build)
+ end
+
+ private
+
+ def build_failed_issue_options
+ {
+ title: "Build Failed ##{build.id}",
+ description: namespace_project_job_url(project.namespace, project, build)
+ }
+ end
+
+ def current_user
+ request.current_user
+ end
+
+ def project
+ build.project
+ end
+end
diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb
index e2276808b90..c01efa9dd5c 100644
--- a/app/serializers/build_entity.rb
+++ b/app/serializers/build_entity.rb
@@ -5,15 +5,15 @@ class BuildEntity < Grape::Entity
expose :name
expose :build_path do |build|
- path_to(:namespace_project_build, build)
+ path_to(:namespace_project_job, build)
end
- expose :retry_path do |build|
- path_to(:retry_namespace_project_build, build)
+ expose :retry_path, if: -> (*) { build&.retryable? } do |build|
+ path_to(:retry_namespace_project_job, build)
end
expose :play_path, if: -> (*) { playable? } do |build|
- path_to(:play_namespace_project_build, build)
+ path_to(:play_namespace_project_job, build)
end
expose :playable?, as: :playable
diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb
index d75a83d0fa5..068013c8829 100644
--- a/app/serializers/deploy_key_entity.rb
+++ b/app/serializers/deploy_key_entity.rb
@@ -11,4 +11,11 @@ class DeployKeyEntity < Grape::Entity
expose :projects, using: ProjectEntity do |deploy_key|
deploy_key.projects.select { |project| options[:user].can?(:read_project, project) }
end
+ expose :can_edit
+
+ private
+
+ def can_edit
+ options[:user].can?(:update_deploy_key, object)
+ end
end
diff --git a/app/serializers/entity_date_helper.rb b/app/serializers/entity_date_helper.rb
index 9607ad55a8b..71d9a65fb58 100644
--- a/app/serializers/entity_date_helper.rb
+++ b/app/serializers/entity_date_helper.rb
@@ -4,7 +4,7 @@ module EntityDateHelper
def interval_in_words(diff)
return 'Not started' unless diff
- "#{distance_of_time_in_words(Time.now, diff)} ago"
+ distance_of_time_in_words(Time.now, diff, scope: 'datetime.time_ago_in_words')
end
# Converts seconds into a hash such as:
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index bc4f68710b2..35df95549b7 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -1,4 +1,6 @@
class IssueEntity < IssuableEntity
+ include RequestAwareEntity
+
expose :branch_name
expose :confidential
expose :assignees, using: API::Entities::UserBasic
@@ -7,4 +9,8 @@ class IssueEntity < IssuableEntity
expose :project_id
expose :milestone, using: API::Entities::Milestone
expose :labels, using: LabelEntity
+
+ expose :web_url do |issue|
+ namespace_project_issue_path(issue.project.namespace, issue.project, issue)
+ end
end
diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb
index 26fea59cda8..7bb981041cc 100644
--- a/app/serializers/merge_request_entity.rb
+++ b/app/serializers/merge_request_entity.rb
@@ -29,7 +29,7 @@ class MergeRequestEntity < IssuableEntity
expose :merge_commit_sha
expose :merge_commit_message
- expose :head_pipeline, with: PipelineEntity, as: :pipeline
+ expose :head_pipeline, with: PipelineDetailsEntity, as: :pipeline
# Booleans
expose :work_in_progress?, as: :work_in_progress
@@ -39,6 +39,7 @@ class MergeRequestEntity < IssuableEntity
expose :commits_count
expose :cannot_be_merged?, as: :has_conflicts
expose :can_be_merged?, as: :can_be_merged
+ expose :remove_source_branch?, as: :remove_source_branch
expose :project_archived do |merge_request|
merge_request.project.archived?
@@ -96,6 +97,14 @@ class MergeRequestEntity < IssuableEntity
presenter(merge_request).target_branch_commits_path
end
+ expose :new_blob_path do |merge_request|
+ if can?(current_user, :push_code, merge_request.project)
+ namespace_project_new_blob_path(merge_request.project.namespace,
+ merge_request.project,
+ merge_request.source_branch)
+ end
+ end
+
expose :conflict_resolution_path do |merge_request|
presenter(merge_request).conflict_resolution_path
end
diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb
new file mode 100644
index 00000000000..130968a44c1
--- /dev/null
+++ b/app/serializers/pipeline_details_entity.rb
@@ -0,0 +1,7 @@
+class PipelineDetailsEntity < PipelineEntity
+ expose :details do
+ expose :legacy_stages, as: :stages, using: StageEntity
+ expose :artifacts, using: BuildArtifactEntity
+ expose :manual_actions, using: BuildActionEntity
+ end
+end
diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb
index 51ad0a3f8ba..6d1fd9d459f 100644
--- a/app/serializers/pipeline_entity.rb
+++ b/app/serializers/pipeline_entity.rb
@@ -5,6 +5,9 @@ class PipelineEntity < Grape::Entity
expose :user, using: UserEntity
expose :active?, as: :active
expose :coverage
+ expose :source
+
+ expose :created_at, :updated_at
expose :path do |pipeline|
namespace_project_pipeline_path(
@@ -13,24 +16,20 @@ class PipelineEntity < Grape::Entity
pipeline)
end
- expose :details do
- expose :detailed_status, as: :status, with: StatusEntity
- expose :duration
- expose :finished_at
- expose :stages, using: StageEntity
- expose :artifacts, using: BuildArtifactEntity
- expose :manual_actions, using: BuildActionEntity
- end
-
expose :flags do
expose :latest?, as: :latest
- expose :triggered?, as: :triggered
expose :stuck?, as: :stuck
expose :has_yaml_errors?, as: :yaml_errors
expose :can_retry?, as: :retryable
expose :can_cancel?, as: :cancelable
end
+ expose :details do
+ expose :detailed_status, as: :status, with: StatusEntity
+ expose :duration
+ expose :finished_at
+ end
+
expose :ref do
expose :name do |pipeline|
pipeline.ref
@@ -38,10 +37,7 @@ class PipelineEntity < Grape::Entity
expose :path do |pipeline|
if pipeline.ref
- namespace_project_tree_path(
- pipeline.project.namespace,
- pipeline.project,
- id: pipeline.ref)
+ project_ref_path(pipeline.project, pipeline.ref)
end
end
@@ -50,7 +46,6 @@ class PipelineEntity < Grape::Entity
end
expose :commit, using: CommitEntity
- expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? }
expose :retry_path, if: -> (*) { can_retry? } do |pipeline|
retry_namespace_project_pipeline_path(pipeline.project.namespace,
@@ -64,7 +59,7 @@ class PipelineEntity < Grape::Entity
pipeline.id)
end
- expose :created_at, :updated_at
+ expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? }
private
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index e37af63774c..661bf17983c 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -1,7 +1,7 @@
class PipelineSerializer < BaseSerializer
InvalidResourceError = Class.new(StandardError)
- entity PipelineEntity
+ entity PipelineDetailsEntity
def with_pagination(request, response)
tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
@@ -13,14 +13,15 @@ class PipelineSerializer < BaseSerializer
def represent(resource, opts = {})
if resource.is_a?(ActiveRecord::Relation)
+
resource = resource.preload([
:retryable_builds,
:cancelable_statuses,
:trigger_requests,
:project,
- { pending_builds: :project },
- { manual_actions: :project },
- { artifacts: :project }
+ :manual_actions,
+ :artifacts,
+ { pending_builds: :project }
])
end
diff --git a/app/serializers/request_aware_entity.rb b/app/serializers/request_aware_entity.rb
index 3039014aaaa..d53fcfb8c1b 100644
--- a/app/serializers/request_aware_entity.rb
+++ b/app/serializers/request_aware_entity.rb
@@ -3,6 +3,7 @@ module RequestAwareEntity
included do
include Gitlab::Routing
+ include GitlabRoutingHelper
include Gitlab::Allowable
end
diff --git a/app/serializers/runner_entity.rb b/app/serializers/runner_entity.rb
new file mode 100644
index 00000000000..ed7dacc2dbd
--- /dev/null
+++ b/app/serializers/runner_entity.rb
@@ -0,0 +1,18 @@
+class RunnerEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id, :description
+
+ expose :edit_path,
+ if: -> (*) { can?(request.current_user, :admin_build, project) && runner.specific? } do |runner|
+ edit_namespace_project_runner_path(project.namespace, project, runner)
+ end
+
+ private
+
+ alias_method :runner, :object
+
+ def project
+ request.project
+ end
+end
diff --git a/app/serializers/user_entity.rb b/app/serializers/user_entity.rb
index 43754ea94f7..876512b12dc 100644
--- a/app/serializers/user_entity.rb
+++ b/app/serializers/user_entity.rb
@@ -1,2 +1,7 @@
class UserEntity < API::Entities::UserBasic
+ include RequestAwareEntity
+
+ expose :path do |user|
+ user_path(user)
+ end
end
diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb
index 76b9f1feda7..8e11a2a36a7 100644
--- a/app/services/akismet_service.rb
+++ b/app/services/akismet_service.rb
@@ -16,7 +16,7 @@ class AkismetService
created_at: DateTime.now,
author: owner.name,
author_email: owner.email,
- referrer: options[:referrer],
+ referrer: options[:referrer]
}
begin
diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb
index 8a000585e89..5ad9a50687c 100644
--- a/app/services/audit_event_service.rb
+++ b/app/services/audit_event_service.rb
@@ -8,7 +8,7 @@ class AuditEventService
with: @details[:with],
target_id: @author.id,
target_type: 'User',
- target_details: @author.name,
+ target_details: @author.name
}
self
diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb
index fd9ff115eab..68f6a8619e5 100644
--- a/app/services/boards/create_service.rb
+++ b/app/services/boards/create_service.rb
@@ -12,6 +12,7 @@ module Boards
def create_board!
board = project.boards.create
+ board.lists.create(list_type: :backlog)
board.lists.create(list_type: :closed)
board
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
index 533e6787855..418fa9afd6e 100644
--- a/app/services/boards/issues/list_service.rb
+++ b/app/services/boards/issues/list_service.rb
@@ -3,7 +3,7 @@ module Boards
class ListService < BaseService
def execute
issues = IssuesFinder.new(current_user, filter_params).execute
- issues = without_board_labels(issues) unless list
+ issues = without_board_labels(issues) unless movable_list?
issues = with_list_label(issues) if movable_list?
issues.order_by_position_and_priority
end
diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb
index e73b1a4361a..ecabb2a48e4 100644
--- a/app/services/boards/issues/move_service.rb
+++ b/app/services/boards/issues/move_service.rb
@@ -38,7 +38,7 @@ module Boards
attrs.merge!(
add_label_ids: add_label_ids,
remove_label_ids: remove_label_ids,
- state_event: issue_state,
+ state_event: issue_state
)
end
diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb
index c579ed4c869..df2a01a69e5 100644
--- a/app/services/boards/lists/list_service.rb
+++ b/app/services/boards/lists/list_service.rb
@@ -2,6 +2,8 @@ module Boards
module Lists
class ListService < BaseService
def execute(board)
+ board.lists.create(list_type: :backlog) unless board.lists.backlog.exists?
+
board.lists
end
end
diff --git a/app/services/ci/create_pipeline_builds_service.rb b/app/services/ci/create_pipeline_builds_service.rb
deleted file mode 100644
index 70fb2c5e38f..00000000000
--- a/app/services/ci/create_pipeline_builds_service.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-module Ci
- class CreatePipelineBuildsService < BaseService
- attr_reader :pipeline
-
- def execute(pipeline)
- @pipeline = pipeline
-
- new_builds.map do |build_attributes|
- create_build(build_attributes)
- end
- end
-
- delegate :project, to: :pipeline
-
- private
-
- def create_build(build_attributes)
- build_attributes = build_attributes.merge(
- pipeline: pipeline,
- project: project,
- ref: pipeline.ref,
- tag: pipeline.tag,
- user: current_user,
- trigger_request: trigger_request
- )
- build = pipeline.builds.create(build_attributes)
-
- # Create the environment before the build starts. This sets its slug and
- # makes it available as an environment variable
- project.environments.find_or_create_by(name: build.expanded_environment_name) if
- build.has_environment?
-
- build
- end
-
- def new_builds
- @new_builds ||= pipeline.config_builds_attributes.
- reject { |build| existing_build_names.include?(build[:name]) }
- end
-
- def existing_build_names
- @existing_build_names ||= pipeline.builds.pluck(:name)
- end
-
- def trigger_request
- return @trigger_request if defined?(@trigger_request)
-
- @trigger_request ||= pipeline.trigger_requests.first
- end
- end
-end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index ccdda08d885..bffec216819 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -2,8 +2,9 @@ module Ci
class CreatePipelineService < BaseService
attr_reader :pipeline
- def execute(ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil)
+ def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil)
@pipeline = Ci::Pipeline.new(
+ source: source,
project: project,
ref: ref,
sha: sha,
@@ -42,14 +43,14 @@ module Ci
return pipeline
end
- unless pipeline.config_builds_attributes.present?
- return error('No builds for this pipeline.')
+ unless pipeline.has_stage_seeds?
+ return error('No stages / jobs for this pipeline.')
end
Ci::Pipeline.transaction do
- pipeline.save
+ update_merge_requests_head_pipeline if pipeline.save
- Ci::CreatePipelineBuildsService
+ Ci::CreatePipelineStagesService
.new(project, current_user)
.execute(pipeline)
end
@@ -61,6 +62,13 @@ module Ci
private
+ def update_merge_requests_head_pipeline
+ return unless pipeline.latest?
+
+ MergeRequest.where(source_project: @pipeline.project, source_branch: @pipeline.ref).
+ update_all(head_pipeline_id: @pipeline.id)
+ end
+
def skip_ci?
return false unless pipeline.git_commit_message
pipeline.git_commit_message =~ /\[(ci[ _-]skip|skip[ _-]ci)\]/i
diff --git a/app/services/ci/create_pipeline_stages_service.rb b/app/services/ci/create_pipeline_stages_service.rb
new file mode 100644
index 00000000000..f2c175adee6
--- /dev/null
+++ b/app/services/ci/create_pipeline_stages_service.rb
@@ -0,0 +1,20 @@
+module Ci
+ class CreatePipelineStagesService < BaseService
+ def execute(pipeline)
+ pipeline.stage_seeds.each do |seed|
+ seed.user = current_user
+
+ seed.create! do |build|
+ ##
+ # Create the environment before the build starts. This sets its slug and
+ # makes it available as an environment variable
+ #
+ if build.has_environment?
+ environment_name = build.expanded_environment_name
+ project.environments.find_or_create_by(name: environment_name)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb
index 8362f01ddb8..beb27a5a597 100644
--- a/app/services/ci/create_trigger_request_service.rb
+++ b/app/services/ci/create_trigger_request_service.rb
@@ -4,7 +4,7 @@ module Ci
trigger_request = trigger.trigger_requests.create(variables: variables)
pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: ref).
- execute(ignore_skip_ci: true, trigger_request: trigger_request)
+ execute(:trigger, ignore_skip_ci: true, trigger_request: trigger_request)
trigger_request if pipeline.persisted?
end
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index f51e9fd1d54..6372e5755db 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -1,7 +1,7 @@
module Ci
class RetryBuildService < ::BaseService
CLONE_ACCESSORS = %i[pipeline project ref tag options commands name
- allow_failure stage stage_idx trigger_request
+ allow_failure stage_id stage stage_idx trigger_request
yaml_variables when environment coverage_regex
description tag_list].freeze
diff --git a/app/services/compare_service.rb b/app/services/compare_service.rb
index ab4c02a97a0..a5ae4927412 100644
--- a/app/services/compare_service.rb
+++ b/app/services/compare_service.rb
@@ -17,18 +17,18 @@ class CompareService
start_branch_name) do |commit|
break unless commit
- compare(commit.sha, target_project, target_branch, straight)
+ compare(commit.sha, target_project, target_branch, straight: straight)
end
end
private
- def compare(source_sha, target_project, target_branch, straight)
+ def compare(source_sha, target_project, target_branch, straight:)
raw_compare = Gitlab::Git::Compare.new(
target_project.repository.raw_repository,
target_branch,
source_sha,
- straight
+ straight: straight
)
Compare.new(raw_compare, target_project, straight: straight)
diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb
index 47f9b2c621c..46823418bb0 100644
--- a/app/services/create_deployment_service.rb
+++ b/app/services/create_deployment_service.rb
@@ -1,71 +1,59 @@
-class CreateDeploymentService < BaseService
- def execute(deployable = nil)
+class CreateDeploymentService
+ attr_reader :job
+
+ delegate :expanded_environment_name,
+ :environment_url,
+ :project,
+ to: :job
+
+ def initialize(job)
+ @job = job
+ end
+
+ def execute
return unless executable?
ActiveRecord::Base.transaction do
- @deployable = deployable
+ environment.external_url = environment_url if environment_url
+ environment.fire_state_event(action)
- @environment = environment
- @environment.external_url = expanded_url if expanded_url
- @environment.fire_state_event(action)
+ return unless environment.save
+ return if environment.stopped?
- return unless @environment.save
- return if @environment.stopped?
-
- deploy.tap do |deployment|
- deployment.update_merge_request_metrics!
- end
+ deploy.tap(&:update_merge_request_metrics!)
end
end
private
def executable?
- project && name.present?
+ project && job.environment.present? && environment
end
def deploy
project.deployments.create(
- environment: @environment,
- ref: params[:ref],
- tag: params[:tag],
- sha: params[:sha],
- user: current_user,
- deployable: @deployable,
- on_stop: options[:on_stop])
+ environment: environment,
+ ref: job.ref,
+ tag: job.tag,
+ sha: job.sha,
+ user: job.user,
+ deployable: job,
+ on_stop: on_stop)
end
def environment
- @environment ||= project.environments.find_or_create_by(name: expanded_name)
- end
-
- def expanded_name
- ExpandVariables.expand(name, variables)
- end
-
- def expanded_url
- return unless url
-
- @expanded_url ||= ExpandVariables.expand(url, variables)
- end
-
- def name
- params[:environment]
- end
-
- def url
- options[:url]
+ @environment ||= job.persisted_environment
end
- def options
- params[:options] || {}
+ def environment_options
+ @environment_options ||= job.options&.dig(:environment) || {}
end
- def variables
- params[:variables] || []
+ def on_stop
+ environment_options[:on_stop]
end
def action
- options[:action] || 'start'
+ environment_options[:action] || 'start'
end
end
diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb
index 38a113caec7..64b3c0118fb 100644
--- a/app/services/delete_branch_service.rb
+++ b/app/services/delete_branch_service.rb
@@ -3,22 +3,14 @@ class DeleteBranchService < BaseService
repository = project.repository
branch = repository.find_branch(branch_name)
- unless branch
- return error('No such branch', 404)
- end
-
- if branch_name == repository.root_ref
- return error('Cannot remove HEAD branch', 405)
- end
-
- if ProtectedBranch.protected?(project, branch_name)
- return error('Protected branch cant be removed', 405)
- end
-
unless current_user.can?(:push_code, project)
return error('You dont have push access to repo', 405)
end
+ unless branch
+ return error('No such branch', 404)
+ end
+
if repository.rm_branch(current_user, branch_name)
success('Branch was removed')
else
diff --git a/app/services/discussions/update_diff_position_service.rb b/app/services/discussions/update_diff_position_service.rb
new file mode 100644
index 00000000000..1ef8d9edbe1
--- /dev/null
+++ b/app/services/discussions/update_diff_position_service.rb
@@ -0,0 +1,41 @@
+module Discussions
+ class UpdateDiffPositionService < BaseService
+ def execute(discussion)
+ result = tracer.trace(discussion.position)
+ return unless result
+
+ position = result[:position]
+ outdated = result[:outdated]
+
+ discussion.notes.each do |note|
+ if outdated
+ note.change_position = position
+ else
+ note.position = position
+ note.change_position = nil
+ end
+ end
+
+ Note.transaction do
+ discussion.notes.each do |note|
+ Gitlab::Timeless.timeless(note, &:save)
+ end
+
+ if outdated && current_user
+ SystemNoteService.diff_discussion_outdated(discussion, project, current_user, position)
+ end
+ end
+ end
+
+ private
+
+ def tracer
+ @tracer ||= Gitlab::Diff::PositionTracer.new(
+ project: project,
+ old_diff_refs: params[:old_diff_refs],
+ new_diff_refs: params[:new_diff_refs],
+ paths: params[:paths]
+ )
+ end
+ end
+end
diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb
index 45411c779cc..f080e6326a1 100644
--- a/app/services/git_push_service.rb
+++ b/app/services/git_push_service.rb
@@ -67,7 +67,7 @@ class GitPushService < BaseService
paths = Set.new
@push_commits.each do |commit|
- commit.raw_diffs(deltas_only: true).each do |diff|
+ commit.raw_deltas.each do |diff|
paths << diff.new_path
end
end
@@ -85,8 +85,10 @@ class GitPushService < BaseService
default = is_default_branch?
push_commits.last(PROCESS_COMMIT_LIMIT).each do |commit|
- ProcessCommitWorker.
- perform_async(project.id, current_user.id, commit.to_hash, default)
+ if commit.matches_cross_reference_regex?
+ ProcessCommitWorker.
+ perform_async(project.id, current_user.id, commit.to_hash, default)
+ end
end
end
@@ -104,7 +106,7 @@ class GitPushService < BaseService
EventCreateService.new.push(@project, current_user, build_push_data)
@project.execute_hooks(build_push_data.dup, :push_hooks)
@project.execute_services(build_push_data.dup, :push_hooks)
- Ci::CreatePipelineService.new(@project, current_user, build_push_data).execute
+ Ci::CreatePipelineService.new(@project, current_user, build_push_data).execute(:push)
if push_remove_branch?
AfterBranchDeleteService
diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb
index 96432837481..7c424fba428 100644
--- a/app/services/git_tag_push_service.rb
+++ b/app/services/git_tag_push_service.rb
@@ -11,7 +11,7 @@ class GitTagPushService < BaseService
SystemHooksService.new.execute_hooks(build_system_push_data.dup, :tag_push_hooks)
project.execute_hooks(@push_data.dup, :tag_push_hooks)
project.execute_services(@push_data.dup, :tag_push_hooks)
- Ci::CreatePipelineService.new(project, current_user, @push_data).execute
+ Ci::CreatePipelineService.new(project, current_user, @push_data).execute(:push)
ProjectCacheWorker.perform_async(project.id, [], [:commit_count, :repository_size])
true
diff --git a/app/services/gravatar_service.rb b/app/services/gravatar_service.rb
index 433ecc2df32..e77e08aa380 100644
--- a/app/services/gravatar_service.rb
+++ b/app/services/gravatar_service.rb
@@ -1,15 +1,20 @@
class GravatarService
include Gitlab::CurrentSettings
- def execute(email, size = nil, scale = 2)
- if current_application_settings.gravatar_enabled? && email.present?
- size = 40 if size.nil? || size <= 0
+ def execute(email, size = nil, scale = 2, username: nil)
+ return unless current_application_settings.gravatar_enabled?
- sprintf gravatar_url,
- hash: Digest::MD5.hexdigest(email.strip.downcase),
- size: size * scale,
- email: email.strip
- end
+ identifier = email.presence || username.presence
+ return unless identifier
+
+ hash = Digest::MD5.hexdigest(identifier.strip.downcase)
+ size = 40 unless size && size > 0
+
+ sprintf gravatar_url,
+ hash: hash,
+ size: size * scale,
+ email: ERB::Util.url_encode(email&.strip || ''),
+ username: ERB::Util.url_encode(username&.strip || '')
end
def gitlab_config
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index dc2ab99b982..e77a3e3eac1 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -148,7 +148,7 @@ class IssuableBaseService < BaseService
execute(params[:description], issuable)
# Avoid a description already set on an issuable to be overwritten by a nil
- params[:description] = description if params.has_key?(:description)
+ params[:description] = description if params.key?(:description)
params.merge!(command_params)
end
@@ -178,7 +178,7 @@ class IssuableBaseService < BaseService
after_create(issuable)
issuable.create_cross_references!(current_user)
execute_hooks(issuable)
- issuable.assignees.each(&:invalidate_cache_counts)
+ invalidate_cache_counts(issuable.assignees, issuable)
end
issuable
@@ -237,7 +237,7 @@ class IssuableBaseService < BaseService
if old_assignees != issuable.assignees
assignees = old_assignees + issuable.assignees.to_a
- assignees.compact.each(&:invalidate_cache_counts)
+ invalidate_cache_counts(assignees.compact, issuable)
end
after_update(issuable)
@@ -330,4 +330,10 @@ class IssuableBaseService < BaseService
create_labels_note(issuable, old_labels) if issuable.labels != old_labels
end
+
+ def invalidate_cache_counts(users, issuable)
+ users.each do |user|
+ user.public_send("invalidate_#{issuable.model_name.singular}_cache_counts")
+ end
+ end
end
diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb
index f1030912c68..85c616ca576 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -28,6 +28,7 @@ module Issues
notification_service.close_issue(issue, current_user) if notifications
todo_service.close_issue(issue, current_user)
execute_hooks(issue, 'close')
+ invalidate_cache_counts(issue.assignees, issue)
end
issue
diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb
index 40fbe354492..80ea6312768 100644
--- a/app/services/issues/reopen_service.rb
+++ b/app/services/issues/reopen_service.rb
@@ -8,6 +8,7 @@ module Issues
create_note(issue)
notification_service.reopen_issue(issue, current_user)
execute_hooks(issue, 'reopen')
+ invalidate_cache_counts(issue.assignees, issue)
end
issue
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index 3a58f6c065d..26906ae7167 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -1,22 +1,38 @@
module Members
class CreateService < BaseService
+ DEFAULT_LIMIT = 100
+
def initialize(source, current_user, params = {})
@source = source
@current_user = current_user
@params = params
+ @error = nil
end
def execute
- return false if params[:user_ids].blank?
+ return error('No users specified.') if params[:user_ids].blank?
+
+ user_ids = params[:user_ids].split(',').uniq
+
+ return error("Too many users specified (limit is #{user_limit})") if
+ user_limit && user_ids.size > user_limit
@source.add_users(
- params[:user_ids].split(','),
+ user_ids,
params[:access_level],
expires_at: params[:expires_at],
current_user: current_user
)
- true
+ success
+ end
+
+ private
+
+ def user_limit
+ limit = params.fetch(:limit, DEFAULT_LIMIT)
+
+ limit && limit < 0 ? nil : limit
end
end
end
diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb
index f2053bda83a..2ffc989ed71 100644
--- a/app/services/merge_requests/close_service.rb
+++ b/app/services/merge_requests/close_service.rb
@@ -13,6 +13,7 @@ module MergeRequests
notification_service.close_mr(merge_request, current_user)
todo_service.close_merge_request(merge_request, current_user)
execute_hooks(merge_request, 'close')
+ invalidate_cache_counts(merge_request.assignees, merge_request)
end
merge_request
diff --git a/app/services/merge_requests/conflicts/resolve_service.rb b/app/services/merge_requests/conflicts/resolve_service.rb
index d74a82effd6..c2c335b8461 100644
--- a/app/services/merge_requests/conflicts/resolve_service.rb
+++ b/app/services/merge_requests/conflicts/resolve_service.rb
@@ -37,11 +37,13 @@ module MergeRequests
private
def write_resolved_file_to_index(merge_index, rugged, file, params)
- new_file = if params[:sections]
- file.resolve_lines(params[:sections]).map(&:text).join("\n")
- elsif params[:content]
- file.resolve_content(params[:content])
- end
+ if params[:sections]
+ new_file = file.resolve_lines(params[:sections]).map(&:text).join("\n")
+
+ new_file << "\n" if file.our_blob.data.ends_with?("\n")
+ elsif params[:content]
+ new_file = file.resolve_content(params[:content])
+ end
our_path = file.our_path
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index b0ae2dfe4ce..71d37797bb4 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -11,7 +11,9 @@ module MergeRequests
merge_request = MergeRequest.new
merge_request.source_project = source_project
+ merge_request.source_branch = params[:source_branch]
merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch)
+ merge_request.head_pipeline = head_pipeline_for(merge_request)
create(merge_request)
end
@@ -22,5 +24,18 @@ module MergeRequests
todo_service.new_merge_request(issuable, current_user)
issuable.cache_merge_request_closes_issues!(current_user)
end
+
+ private
+
+ def head_pipeline_for(merge_request)
+ return unless merge_request.source_project
+
+ sha = merge_request.source_branch_sha
+ return unless sha
+
+ pipelines = merge_request.source_project.pipelines.where(ref: merge_request.source_branch, sha: sha)
+
+ pipelines.order(id: :desc).first
+ end
end
end
diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb
index e8fb1b59752..f0d998731d7 100644
--- a/app/services/merge_requests/post_merge_service.rb
+++ b/app/services/merge_requests/post_merge_service.rb
@@ -13,6 +13,7 @@ module MergeRequests
create_note(merge_request)
notification_service.merge_mr(merge_request, current_user)
execute_hooks(merge_request, 'merge')
+ invalidate_cache_counts(merge_request.assignees, merge_request)
end
private
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index 1131d6f4913..81d217929d5 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -66,12 +66,12 @@ module MergeRequests
filter_merge_requests(merge_requests).each do |merge_request|
if merge_request.source_branch == @branch_name || force_push?
- merge_request.reload_diff
+ merge_request.reload_diff(current_user)
else
mr_commit_ids = merge_request.commits_sha
push_commit_ids = @commits.map(&:id)
matches = mr_commit_ids & push_commit_ids
- merge_request.reload_diff if matches.any?
+ merge_request.reload_diff(current_user) if matches.any?
end
merge_request.mark_as_unchecked
diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb
index fadcce5d9b6..f2fddf7f345 100644
--- a/app/services/merge_requests/reopen_service.rb
+++ b/app/services/merge_requests/reopen_service.rb
@@ -8,8 +8,9 @@ module MergeRequests
create_note(merge_request)
notification_service.reopen_mr(merge_request, current_user)
execute_hooks(merge_request, 'reopen')
- merge_request.reload_diff
+ merge_request.reload_diff(current_user)
merge_request.mark_as_unchecked
+ invalidate_cache_counts(merge_request.assignees, merge_request)
end
merge_request
diff --git a/app/services/metrics_service.rb b/app/services/metrics_service.rb
new file mode 100644
index 00000000000..d726db4e99b
--- /dev/null
+++ b/app/services/metrics_service.rb
@@ -0,0 +1,33 @@
+require 'prometheus/client/formats/text'
+
+class MetricsService
+ CHECKS = [
+ Gitlab::HealthChecks::DbCheck,
+ Gitlab::HealthChecks::RedisCheck,
+ Gitlab::HealthChecks::FsShardsCheck
+ ].freeze
+
+ def prometheus_metrics_text
+ Prometheus::Client::Formats::Text.marshal_multiprocess(multiprocess_metrics_path)
+ end
+
+ def health_metrics_text
+ metrics = CHECKS.flat_map(&:metrics)
+
+ formatter.marshal(metrics)
+ end
+
+ def metrics_text
+ "#{health_metrics_text}#{prometheus_metrics_text}"
+ end
+
+ private
+
+ def formatter
+ @formatter ||= Gitlab::HealthChecks::PrometheusTextFormat.new
+ end
+
+ def multiprocess_metrics_path
+ @multiprocess_metrics_path ||= Rails.root.join(ENV['prometheus_multiproc_dir']).freeze
+ end
+end
diff --git a/app/services/notes/diff_position_update_service.rb b/app/services/notes/diff_position_update_service.rb
deleted file mode 100644
index 0cb731f5bc3..00000000000
--- a/app/services/notes/diff_position_update_service.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-module Notes
- class DiffPositionUpdateService < BaseService
- def execute(note)
- new_position = tracer.trace(note.position)
-
- # Don't update the position if the type doesn't match, since that means
- # the diff line commented on was changed, and the comment is now outdated
- old_position = note.position
- if new_position &&
- new_position != old_position &&
- new_position.type == old_position.type
-
- note.position = new_position
- end
-
- note
- end
-
- private
-
- def tracer
- @tracer ||= Gitlab::Diff::PositionTracer.new(
- repository: project.repository,
- old_diff_refs: params[:old_diff_refs],
- new_diff_refs: params[:new_diff_refs],
- paths: params[:paths]
- )
- end
- end
-end
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index c65c66d7150..646ccbdb2bf 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -298,7 +298,7 @@ class NotificationService
recipients ||= NotificationRecipientService.new(pipeline.project).build_pipeline_recipients(
pipeline,
pipeline.user,
- action: pipeline.status,
+ action: pipeline.status
).map(&:notification_email)
if recipients.any?
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 535d93385e6..e874a2d8789 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -48,15 +48,14 @@ module Projects
save_project_and_import_data(import_data)
- @project.import_start if @project.import?
-
after_create_actions if @project.persisted?
if @project.errors.empty?
- @project.add_import_job if @project.import?
+ @project.import_schedule if @project.import?
else
fail(error: @project.errors.full_messages.join(', '))
end
+
@project
rescue ActiveRecord::RecordInvalid => e
message = "Unable to save #{e.record.type}: #{e.record.errors.full_messages.join(", ")} "
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 06d8d143231..e2b2660ea71 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -7,11 +7,9 @@ module Projects
DELETED_FLAG = '+deleted'.freeze
def async_execute
- project.transaction do
- project.update_attribute(:pending_delete, true)
- job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params)
- Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.path_with_namespace} with job ID #{job_id}")
- end
+ project.update_attribute(:pending_delete, true)
+ job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params)
+ Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.path_with_namespace} with job ID #{job_id}")
end
def execute
@@ -62,7 +60,11 @@ module Projects
if gitlab_shell.mv_repository(project.repository_storage_path, path, new_path)
log_info("Repository \"#{path}\" moved to \"#{new_path}\"")
- GitlabShellWorker.perform_in(5.minutes, :remove_repository, project.repository_storage_path, new_path)
+
+ project.run_after_commit do
+ # self is now project
+ GitlabShellWorker.perform_in(5.minutes, :remove_repository, self.repository_storage_path, new_path)
+ end
else
false
end
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index da6e6acd4a7..1c24b27a870 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -12,12 +12,13 @@ module Projects
TransferError = Class.new(StandardError)
def execute(new_namespace)
- if allowed_transfer?(current_user, project, new_namespace)
- transfer(project, new_namespace)
- else
- project.errors.add(:new_namespace, 'is invalid')
- false
+ if new_namespace.blank?
+ raise TransferError, 'Please select a new namespace for your project.'
end
+ unless allowed_transfer?(current_user, project, new_namespace)
+ raise TransferError, 'Transfer failed, please contact an admin.'
+ end
+ transfer(project, new_namespace)
rescue Projects::TransferService::TransferError => ex
project.reload
project.errors.add(:new_namespace, ex.message)
diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb
index eb4809afa85..cacb74b1205 100644
--- a/app/services/projects/update_pages_configuration_service.rb
+++ b/app/services/projects/update_pages_configuration_service.rb
@@ -27,7 +27,7 @@ module Projects
{
domain: domain.domain,
certificate: domain.certificate,
- key: domain.key,
+ key: domain.key
}
end
end
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 22736c71725..1d4d03a8b7d 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -12,7 +12,7 @@ class SearchService
@project =
if params[:project_id].present?
the_project = Project.find_by(id: params[:project_id])
- can?(current_user, :download_code, the_project) ? the_project : nil
+ can?(current_user, :read_project, the_project) ? the_project : nil
else
nil
end
diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb
index a7e13648b54..b6b411d2185 100644
--- a/app/services/slash_commands/interpret_service.rb
+++ b/app/services/slash_commands/interpret_service.rb
@@ -92,26 +92,20 @@ module SlashCommands
desc 'Assign'
explanation do |users|
- "Assigns #{users.map(&:to_reference).to_sentence}." if users.any?
+ "Assigns #{users.first.to_reference}." if users.any?
end
params '@user'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
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
+ extract_users(assignee_param)
end
command :assign do |users|
next if users.empty?
if issuable.is_a?(Issue)
- @updates[:assignee_ids] = users.map(&:id)
+ @updates[:assignee_ids] = [users.last.id]
else
@updates[:assignee_id] = users.last.id
end
@@ -459,6 +453,18 @@ module SlashCommands
end
end
+ def extract_users(params)
+ return [] if params.nil?
+
+ users = extract_references(params, :user)
+
+ if users.empty?
+ users = User.where(username: params.split(' ').map(&:strip))
+ end
+
+ users
+ end
+
def find_labels(labels_param)
extract_references(labels_param, :label) |
LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute
diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb
new file mode 100644
index 00000000000..17857ca62f2
--- /dev/null
+++ b/app/services/submit_usage_ping_service.rb
@@ -0,0 +1,41 @@
+class SubmitUsagePingService
+ URL = 'https://version.gitlab.com/usage_data'.freeze
+
+ include Gitlab::CurrentSettings
+
+ def execute
+ return false unless current_application_settings.usage_ping_enabled?
+
+ response = HTTParty.post(
+ URL,
+ body: Gitlab::UsageData.to_json(force_refresh: true),
+ headers: { 'Content-type' => 'application/json' }
+ )
+
+ store_metrics(response)
+
+ true
+ rescue HTTParty::Error => e
+ Rails.logger.info "Unable to contact GitLab, Inc.: #{e}"
+
+ false
+ end
+
+ private
+
+ def store_metrics(response)
+ return unless response['conv_index'].present?
+
+ ConversationalDevelopmentIndex::Metric.create!(
+ response['conv_index'].slice(
+ 'leader_issues', 'instance_issues', 'leader_notes', 'instance_notes',
+ 'leader_milestones', 'instance_milestones', 'leader_boards', 'instance_boards',
+ 'leader_merge_requests', 'instance_merge_requests', 'leader_ci_pipelines',
+ 'instance_ci_pipelines', 'leader_environments', 'instance_environments',
+ 'leader_deployments', 'instance_deployments', 'leader_projects_prometheus_active',
+ 'instance_projects_prometheus_active', 'leader_service_desk_issues',
+ 'instance_service_desk_issues'
+ )
+ )
+ end
+end
diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb
index af0ddbe5934..ed476fc9d0c 100644
--- a/app/services/system_hooks_service.rb
+++ b/app/services/system_hooks_service.rb
@@ -51,7 +51,7 @@ class SystemHooksService
path: model.path,
group_id: model.id,
owner_name: owner.respond_to?(:name) ? owner.name : nil,
- owner_email: owner.respond_to?(:email) ? owner.email : nil,
+ owner_email: owner.respond_to?(:email) ? owner.email : nil
)
when GroupMember
data.merge!(group_member_data(model))
@@ -113,7 +113,7 @@ class SystemHooksService
user_name: model.user.name,
user_email: model.user.email,
user_id: model.user.id,
- group_access: model.human_access,
+ group_access: model.human_access
}
end
end
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 0766df50ed2..0837c07e6aa 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -258,7 +258,7 @@ module SystemNoteService
create_note(NoteSummary.new(noteable, project, author, body, action: 'title'))
end
- def self.resolve_all_discussions(merge_request, project, author)
+ def resolve_all_discussions(merge_request, project, author)
body = "resolved all discussions"
create_note(NoteSummary.new(merge_request, project, author, body, action: 'discussion'))
@@ -274,6 +274,28 @@ module SystemNoteService
note
end
+ def diff_discussion_outdated(discussion, project, author, change_position)
+ merge_request = discussion.noteable
+ diff_refs = change_position.diff_refs
+ version_index = merge_request.merge_request_diffs.viewable.count
+
+ body = "changed this line in"
+ if version_params = merge_request.version_params_for(diff_refs)
+ line_code = change_position.line_code(project.repository)
+ url = url_helpers.diffs_namespace_project_merge_request_url(project.namespace, project, merge_request, version_params.merge(anchor: line_code))
+
+ body << " [version #{version_index} of the diff](#{url})"
+ else
+ body << " version #{version_index} of the diff"
+ end
+
+ note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body)
+ note = Note.create(note_attributes.merge(system: true))
+ note.system_note_metadata = SystemNoteMetadata.new(action: 'outdated')
+
+ note
+ end
+
# Called when the title of a Noteable is changed
#
# noteable - Noteable object that responds to `title`
@@ -291,8 +313,8 @@ module SystemNoteService
old_diffs, new_diffs = Gitlab::Diff::InlineDiff.new(old_title, new_title).inline_diffs
- marked_old_title = Gitlab::Diff::InlineDiffMarker.new(old_title).mark(old_diffs, mode: :deletion, markdown: true)
- marked_new_title = Gitlab::Diff::InlineDiffMarker.new(new_title).mark(new_diffs, mode: :addition, markdown: true)
+ marked_old_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(old_title).mark(old_diffs, mode: :deletion)
+ marked_new_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(new_title).mark(new_diffs, mode: :addition)
body = "changed title from **#{marked_old_title}** to **#{marked_new_title}**"
diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb
index facf21a7f5c..ab532a1fdcf 100644
--- a/app/services/users/activity_service.rb
+++ b/app/services/users/activity_service.rb
@@ -16,7 +16,7 @@ module Users
def record_activity
Gitlab::UserActivities.record(@author.id)
- Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username}")
+ Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username})")
end
end
end
diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb
index 9eb6a600f6b..673afb8b5b9 100644
--- a/app/services/users/destroy_service.rb
+++ b/app/services/users/destroy_service.rb
@@ -6,12 +6,27 @@ module Users
@current_user = current_user
end
+ # Synchronously destroys +user+
+ #
+ # The operation will fail if the user is the sole owner of any groups. To
+ # force the groups to be destroyed, pass `delete_solo_owned_groups: true` in
+ # +options+.
+ #
+ # The user's contributions will be migrated to a global ghost user. To
+ # force the contributions to be destroyed, pass `hard_delete: true` in
+ # +options+.
+ #
+ # `hard_delete: true` implies `delete_solo_owned_groups: true`. To perform
+ # a hard deletion without destroying solo-owned groups, pass
+ # `delete_solo_owned_groups: false, hard_delete: true` in +options+.
def execute(user, options = {})
+ delete_solo_owned_groups = options.fetch(:delete_solo_owned_groups, options[:hard_delete])
+
unless Ability.allowed?(current_user, :destroy_user, user)
raise Gitlab::Access::AccessDeniedError, "#{current_user} tried to destroy user #{user}!"
end
- if !options[:delete_solo_owned_groups] && user.solo_owned_groups.present?
+ if !delete_solo_owned_groups && user.solo_owned_groups.present?
user.errors[:base] << 'You must transfer ownership or delete groups before you can remove user'
return user
end
diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb
new file mode 100644
index 00000000000..4241b912d5b
--- /dev/null
+++ b/app/services/web_hook_service.rb
@@ -0,0 +1,120 @@
+class WebHookService
+ class InternalErrorResponse
+ attr_reader :body, :headers, :code
+
+ def initialize
+ @headers = HTTParty::Response::Headers.new({})
+ @body = ''
+ @code = 'internal error'
+ end
+ end
+
+ include HTTParty
+
+ # HTTParty timeout
+ default_timeout Gitlab.config.gitlab.webhook_timeout
+
+ attr_accessor :hook, :data, :hook_name
+
+ def initialize(hook, data, hook_name)
+ @hook = hook
+ @data = data
+ @hook_name = hook_name
+ end
+
+ def execute
+ start_time = Time.now
+
+ response = if parsed_url.userinfo.blank?
+ make_request(hook.url)
+ else
+ make_request_with_auth
+ end
+
+ log_execution(
+ trigger: hook_name,
+ url: hook.url,
+ request_data: data,
+ response: response,
+ execution_duration: Time.now - start_time
+ )
+
+ [response.code, response.to_s]
+ rescue SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout => e
+ log_execution(
+ trigger: hook_name,
+ url: hook.url,
+ request_data: data,
+ response: InternalErrorResponse.new,
+ execution_duration: Time.now - start_time,
+ error_message: e.to_s
+ )
+
+ Rails.logger.error("WebHook Error => #{e}")
+
+ [nil, e.to_s]
+ end
+
+ def async_execute
+ Sidekiq::Client.enqueue(WebHookWorker, hook.id, data, hook_name)
+ end
+
+ private
+
+ def parsed_url
+ @parsed_url ||= URI.parse(hook.url)
+ end
+
+ def make_request(url, basic_auth = false)
+ self.class.post(url,
+ body: data.to_json,
+ headers: build_headers(hook_name),
+ verify: hook.enable_ssl_verification,
+ basic_auth: basic_auth)
+ end
+
+ def make_request_with_auth
+ post_url = hook.url.gsub("#{parsed_url.userinfo}@", '')
+ basic_auth = {
+ username: CGI.unescape(parsed_url.user),
+ password: CGI.unescape(parsed_url.password)
+ }
+ make_request(post_url, basic_auth)
+ end
+
+ def log_execution(trigger:, url:, request_data:, response:, execution_duration:, error_message: nil)
+ # logging for ServiceHook's is not available
+ return if hook.is_a?(ServiceHook)
+
+ WebHookLog.create(
+ web_hook: hook,
+ trigger: trigger,
+ url: url,
+ execution_duration: execution_duration,
+ request_headers: build_headers(hook_name),
+ request_data: request_data,
+ response_headers: format_response_headers(response),
+ response_body: response.body,
+ response_status: response.code,
+ internal_error_message: error_message
+ )
+ end
+
+ def build_headers(hook_name)
+ @headers ||= begin
+ {
+ 'Content-Type' => 'application/json',
+ 'X-Gitlab-Event' => hook_name.singularize.titleize
+ }.tap do |hash|
+ hash['X-Gitlab-Token'] = hook.token if hook.token.present?
+ end
+ end
+ end
+
+ # Make response headers more stylish
+ # Net::HTTPHeader has downcased hash with arrays: { 'content-type' => ['text/html; charset=utf-8'] }
+ # This method format response to capitalized hash with strings: { 'Content-Type' => 'text/html; charset=utf-8' }
+ def format_response_headers(response)
+ response.headers.each_capitalized.to_h
+ end
+end
diff --git a/app/uploaders/artifact_uploader.rb b/app/uploaders/artifact_uploader.rb
index 3e36ec91205..3bc0408f557 100644
--- a/app/uploaders/artifact_uploader.rb
+++ b/app/uploaders/artifact_uploader.rb
@@ -1,33 +1,35 @@
class ArtifactUploader < GitlabUploader
storage :file
- attr_accessor :build, :field
+ attr_reader :job, :field
- def self.artifacts_path
+ def self.local_artifacts_store
Gitlab.config.artifacts.path
end
def self.artifacts_upload_path
- File.join(self.artifacts_path, 'tmp/uploads/')
+ File.join(self.local_artifacts_store, 'tmp/uploads/')
end
- def self.artifacts_cache_path
- File.join(self.artifacts_path, 'tmp/cache/')
- end
-
- def initialize(build, field)
- @build, @field = build, field
+ def initialize(job, field)
+ @job, @field = job, field
end
def store_dir
- File.join(self.class.artifacts_path, @build.artifacts_path)
+ default_local_path
end
def cache_dir
- File.join(self.class.artifacts_cache_path, @build.artifacts_path)
+ File.join(self.class.local_artifacts_store, 'tmp/cache')
+ end
+
+ private
+
+ def default_local_path
+ File.join(self.class.local_artifacts_store, default_path)
end
- def filename
- file.try(:filename)
+ def default_path
+ File.join(job.created_at.utc.strftime('%Y_%m'), job.project_id.to_s, job.id.to_s)
end
end
diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb
new file mode 100644
index 00000000000..00c2888d224
--- /dev/null
+++ b/app/uploaders/file_mover.rb
@@ -0,0 +1,63 @@
+class FileMover
+ attr_reader :secret, :file_name, :model, :update_field
+
+ def initialize(file_path, model, update_field = :description)
+ @secret = File.split(File.dirname(file_path)).last
+ @file_name = File.basename(file_path)
+ @model = model
+ @update_field = update_field
+ end
+
+ def execute
+ move
+ uploader.record_upload if update_markdown
+ end
+
+ private
+
+ def move
+ FileUtils.mkdir_p(File.dirname(file_path))
+ FileUtils.move(temp_file_path, file_path)
+ end
+
+ def update_markdown
+ updated_text = model.read_attribute(update_field).gsub(temp_file_uploader.to_markdown, uploader.to_markdown)
+ model.update_attribute(update_field, updated_text)
+
+ true
+ rescue
+ revert
+
+ false
+ end
+
+ def temp_file_path
+ return @temp_file_path if @temp_file_path
+
+ temp_file_uploader.retrieve_from_store!(file_name)
+
+ @temp_file_path = temp_file_uploader.file.path
+ end
+
+ def file_path
+ return @file_path if @file_path
+
+ uploader.retrieve_from_store!(file_name)
+
+ @file_path = uploader.file.path
+ end
+
+ def uploader
+ @uploader ||= PersonalFileUploader.new(model, secret)
+ end
+
+ def temp_file_uploader
+ @temp_file_uploader ||= PersonalFileUploader.new(nil, secret)
+ end
+
+ def revert
+ Rails.logger.warn("Markdown not updated, file move reverted for #{model}")
+
+ FileUtils.move(file_path, temp_file_path)
+ end
+end
diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb
index 449850bf0d5..a50169a5076 100644
--- a/app/uploaders/gitlab_uploader.rb
+++ b/app/uploaders/gitlab_uploader.rb
@@ -19,8 +19,12 @@ class GitlabUploader < CarrierWave::Uploader::Base
File.join(root_dir, 'system')
end
- def self.file_storage?
- self.storage == CarrierWave::Storage::File
+ def file_storage?
+ storage.is_a?(CarrierWave::Storage::File)
+ end
+
+ def file_cache_storage?
+ cache_storage.is_a?(CarrierWave::Storage::File)
end
delegate :base_dir, :file_storage?, to: :class
diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb
index 95a891111e1..02589959c2f 100644
--- a/app/uploaders/lfs_object_uploader.rb
+++ b/app/uploaders/lfs_object_uploader.rb
@@ -12,4 +12,20 @@ class LfsObjectUploader < GitlabUploader
def filename
model.oid[4..-1]
end
+
+ def work_dir
+ File.join(Gitlab.config.lfs.storage_path, 'tmp', 'work')
+ end
+
+ private
+
+ # To prevent LFS files from moving across filesystems, override the default
+ # implementation:
+ # http://github.com/carrierwaveuploader/carrierwave/blob/v1.0.0/lib/carrierwave/uploader/cache.rb#L181-L183
+ def workfile_path(for_file = original_filename)
+ # To be safe, keep this directory outside of the the cache directory
+ # because calling CarrierWave.clean_cache_files! will remove any files in
+ # the cache directory.
+ File.join(work_dir, @cache_id, version_name.to_s, for_file)
+ end
end
diff --git a/app/uploaders/personal_file_uploader.rb b/app/uploaders/personal_file_uploader.rb
index 969b0a20d38..7f857765fbf 100644
--- a/app/uploaders/personal_file_uploader.rb
+++ b/app/uploaders/personal_file_uploader.rb
@@ -10,6 +10,10 @@ class PersonalFileUploader < FileUploader
end
def self.model_path(model)
- File.join("/#{base_dir}", model.class.to_s.underscore, model.id.to_s)
+ if model
+ File.join("/#{base_dir}", model.class.to_s.underscore, model.id.to_s)
+ else
+ File.join("/#{base_dir}", 'temp')
+ end
end
end
diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb
index 4c127f29250..feb4f04d7b7 100644
--- a/app/uploaders/records_uploads.rb
+++ b/app/uploaders/records_uploads.rb
@@ -6,8 +6,6 @@ module RecordsUploads
before :remove, :destroy_upload
end
- private
-
# After storing an attachment, create a corresponding Upload record
#
# NOTE: We're ignoring the argument passed to this callback because we want
@@ -15,13 +13,16 @@ module RecordsUploads
# `Tempfile` object the callback gets.
#
# Called `after :store`
- def record_upload(_tempfile)
+ def record_upload(_tempfile = nil)
+ return unless model
return unless file_storage?
return unless file.exists?
Upload.record(self)
end
+ private
+
# Before removing an attachment, destroy any Upload records at the same path
#
# Called `before :remove`
diff --git a/app/validators/dynamic_path_validator.rb b/app/validators/dynamic_path_validator.rb
index 8d4d7180baf..27ac60637fd 100644
--- a/app/validators/dynamic_path_validator.rb
+++ b/app/validators/dynamic_path_validator.rb
@@ -3,16 +3,25 @@
# 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
+# Values are checked for formatting and exclusion from a list of illegal path
# names.
class DynamicPathValidator < ActiveModel::EachValidator
+ extend Gitlab::EncodingHelper
+
class << self
- def valid_namespace_path?(path)
- "#{path}/" =~ Gitlab::Regex.full_namespace_path_regex
+ def valid_user_path?(path)
+ encode!(path)
+ "#{path}/" =~ Gitlab::PathRegex.root_namespace_path_regex
+ end
+
+ def valid_group_path?(path)
+ encode!(path)
+ "#{path}/" =~ Gitlab::PathRegex.full_namespace_path_regex
end
def valid_project_path?(path)
- "#{path}/" =~ Gitlab::Regex.full_project_path_regex
+ encode!(path)
+ "#{path}/" =~ Gitlab::PathRegex.full_project_path_regex
end
end
@@ -24,14 +33,16 @@ class DynamicPathValidator < ActiveModel::EachValidator
case record
when Project
self.class.valid_project_path?(full_path)
- else
- self.class.valid_namespace_path?(full_path)
+ when Group
+ self.class.valid_group_path?(full_path)
+ else # User or non-Group Namespace
+ self.class.valid_user_path?(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)
+ unless value =~ Gitlab::PathRegex.namespace_format_regex
+ record.errors.add(attribute, Gitlab::PathRegex.namespace_format_message)
return
end
diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
index e1b4e34cd2b..d552704df88 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -232,7 +232,7 @@
= f.number_field :container_registry_token_expire_delay, class: 'form-control'
%fieldset
- %legend Metrics
+ %legend Metrics - Influx
%p
Setup InfluxDB to measure a wide variety of statistics like the time spent
in running SQL queries. These settings require a
@@ -297,6 +297,21 @@
results in fewer but larger UDP packets being sent.
%fieldset
+ %legend Metrics - Prometheus
+ %p
+ Setup Prometheus to measure a variety of statistics that partially overlap and complement Influx based metrics.
+ This setting requires a
+ = link_to 'restart', help_page_path('administration/restart_gitlab')
+ to take effect.
+ = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/introduction')
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :prometheus_metrics_enabled do
+ = f.check_box :prometheus_metrics_enabled
+ Enable Prometheus Metrics
+
+ %fieldset
%legend Background Jobs
%p
These settings require a restart to take effect.
diff --git a/app/views/admin/background_jobs/show.html.haml b/app/views/admin/background_jobs/show.html.haml
index ac36bb5bb17..e5842bd1ea0 100644
--- a/app/views/admin/background_jobs/show.html.haml
+++ b/app/views/admin/background_jobs/show.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
- page_title "Background Jobs"
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
%h3.page-title Background Jobs
diff --git a/app/views/admin/conversational_development_index/_callout.html.haml b/app/views/admin/conversational_development_index/_callout.html.haml
new file mode 100644
index 00000000000..33a4dab1e00
--- /dev/null
+++ b/app/views/admin/conversational_development_index/_callout.html.haml
@@ -0,0 +1,13 @@
+.prepend-top-default
+.user-callout{ data: { uid: 'convdev_intro_callout_dismissed' } }
+ .bordered-box.landing.content-block
+ %button.btn.btn-default.close.js-close-callout{ type: 'button',
+ 'aria-label' => 'Dismiss ConvDev introduction' }
+ = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
+ .user-callout-copy
+ %h4
+ Introducing Your Conversational Development Index
+ %p
+ Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.
+ .svg-container.convdev
+ = custom_icon('convdev_overview')
diff --git a/app/views/admin/conversational_development_index/_card.html.haml b/app/views/admin/conversational_development_index/_card.html.haml
new file mode 100644
index 00000000000..6c8688e06ae
--- /dev/null
+++ b/app/views/admin/conversational_development_index/_card.html.haml
@@ -0,0 +1,25 @@
+.convdev-card-wrapper
+ .convdev-card{ class: "convdev-card-#{score_level(card.percentage_score)}" }
+ .convdev-card-title
+ %h3
+ = card.title
+ .text-light
+ = card.description
+ .card-scores
+ .card-score
+ .card-score-value
+ = format_score(card.instance_score)
+ .card-score-name You
+ .card-score
+ .card-score-value
+ = format_score(card.leader_score)
+ .card-score-name Lead
+ .card-score-big
+ = number_to_percentage(card.percentage_score, precision: 1)
+ .card-buttons
+ - if card.blog
+ %a{ href: card.blog }
+ = icon('info-circle', 'aria-hidden' => 'true')
+ - if card.docs
+ %a{ href: card.docs }
+ = icon('question-circle', 'aria-hidden' => 'true')
diff --git a/app/views/admin/conversational_development_index/_disabled.html.haml b/app/views/admin/conversational_development_index/_disabled.html.haml
new file mode 100644
index 00000000000..975d7df3da6
--- /dev/null
+++ b/app/views/admin/conversational_development_index/_disabled.html.haml
@@ -0,0 +1,9 @@
+.container.convdev-empty
+ .col-sm-6.col-sm-push-3.text-center
+ = custom_icon('convdev_no_index')
+ %h4 Usage ping is not enabled
+ %p
+ ConvDev is only shown when the
+ = link_to 'usage ping', help_page_path('user/admin_area/settings/usage_statistics'), target: '_blank'
+ is enabled. Enable usage ping to get an overview of how you are using GitLab from a feature perspective
+ = link_to 'Enable usage ping', admin_application_settings_path(anchor: 'usage-statistics'), class: 'btn btn-primary'
diff --git a/app/views/admin/conversational_development_index/_no_data.html.haml b/app/views/admin/conversational_development_index/_no_data.html.haml
new file mode 100644
index 00000000000..b23d2b5ec3a
--- /dev/null
+++ b/app/views/admin/conversational_development_index/_no_data.html.haml
@@ -0,0 +1,7 @@
+.container.convdev-empty
+ .col-sm-6.col-sm-push-3.text-center
+ = custom_icon('convdev_no_data')
+ %h4 Data is still calculating...
+ %p
+ In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index.
+ = link_to 'Learn more', help_page_path('user/admin_area/monitoring/convdev'), target: '_blank'
diff --git a/app/views/admin/conversational_development_index/show.html.haml b/app/views/admin/conversational_development_index/show.html.haml
new file mode 100644
index 00000000000..833d4c612f8
--- /dev/null
+++ b/app/views/admin/conversational_development_index/show.html.haml
@@ -0,0 +1,35 @@
+- @no_container = true
+- page_title 'ConvDev Index'
+
+= render 'admin/monitoring/head'
+
+.container
+ - if show_callout?('convdev_intro_callout_dismissed')
+ = render 'callout'
+
+ .prepend-top-default
+ - if !current_application_settings.usage_ping_enabled
+ = render 'disabled'
+ - elsif @metric.blank?
+ = render 'no_data'
+ - else
+ .convdev
+ .convdev-header
+ %h2.convdev-header-title{ class: "convdev-#{score_level(@metric.average_percentage_score)}-score" }
+ = number_to_percentage(@metric.average_percentage_score, precision: 1)
+ .convdev-header-subtitle
+ index
+ %br
+ score
+ = link_to icon('question-circle', 'aria-hidden' => 'true'), help_page_path('user/admin_area/monitoring/convdev')
+
+ .convdev-cards.card-container
+ - @metric.cards.each do |card|
+ = render 'card', card: card
+
+ .convdev-steps.visible-lg
+ - @metric.idea_to_production_steps.each_with_index do |step, index|
+ .convdev-step{ class: "convdev-#{score_level(step.percentage_score)}-score" }
+ = custom_icon("i2p_step_#{index + 1}")
+ %h4.convdev-step-title
+ = step.title
diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml
index 163bd5662b0..dff549f502c 100644
--- a/app/views/admin/dashboard/_head.html.haml
+++ b/app/views/admin/dashboard/_head.html.haml
@@ -20,7 +20,7 @@
%span
Groups
= nav_link path: 'builds#index' do
- = link_to admin_builds_path, title: 'Jobs' do
+ = link_to admin_jobs_path, title: 'Jobs' do
%span
Jobs
= nav_link path: ['runners#index', 'runners#show'] do
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 53f0a1e7fde..3c9f932a225 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -79,6 +79,12 @@
= gitlab_pages
%span.light.pull-right
= boolean_to_icon gitlab_pages_enabled
+ - gitlab_shared_runners = 'Shared Runners'
+ - gitlab_shared_runners_enabled = Gitlab.config.gitlab_ci.shared_runners_enabled
+ %p{ "aria-label" => "#{gitlab_shared_runners}: status " + (gitlab_shared_runners_enabled ? "on" : "off") }
+ = gitlab_shared_runners
+ %span.light.pull-right
+ = boolean_to_icon gitlab_shared_runners_enabled
.col-md-4
%h4
diff --git a/app/views/admin/deploy_keys/edit.html.haml b/app/views/admin/deploy_keys/edit.html.haml
new file mode 100644
index 00000000000..3a59282e578
--- /dev/null
+++ b/app/views/admin/deploy_keys/edit.html.haml
@@ -0,0 +1,10 @@
+- page_title 'Edit Deploy Key'
+%h3.page-title Edit public deploy key
+%hr
+
+%div
+ = form_for [:admin, @deploy_key], html: { class: 'deploy-key-form form-horizontal' } do |f|
+ = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
+ .form-actions
+ = f.submit 'Save changes', class: 'btn-save btn'
+ = link_to 'Cancel', admin_deploy_keys_path, class: 'btn btn-cancel'
diff --git a/app/views/admin/deploy_keys/index.html.haml b/app/views/admin/deploy_keys/index.html.haml
index 007da8c1d29..92370034baa 100644
--- a/app/views/admin/deploy_keys/index.html.haml
+++ b/app/views/admin/deploy_keys/index.html.haml
@@ -31,4 +31,6 @@
%span.cgray
added #{time_ago_with_tooltip(deploy_key.created_at)}
%td
- = link_to 'Remove', admin_deploy_key_path(deploy_key), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-sm btn-remove delete-key pull-right'
+ .pull-right
+ = link_to 'Edit', edit_admin_deploy_key_path(deploy_key), class: 'btn btn-sm'
+ = link_to 'Remove', admin_deploy_key_path(deploy_key), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-sm btn-remove delete-key'
diff --git a/app/views/admin/deploy_keys/new.html.haml b/app/views/admin/deploy_keys/new.html.haml
index a064efc231f..13f5259698f 100644
--- a/app/views/admin/deploy_keys/new.html.haml
+++ b/app/views/admin/deploy_keys/new.html.haml
@@ -1,31 +1,10 @@
-- page_title "New Deploy Key"
+- page_title 'New Deploy Key'
%h3.page-title New public deploy key
%hr
%div
= form_for [:admin, @deploy_key], html: { class: 'deploy-key-form form-horizontal' } do |f|
- = form_errors(@deploy_key)
-
- .form-group
- = f.label :title, class: "control-label"
- .col-sm-10= f.text_field :title, class: 'form-control'
- .form-group
- = f.label :key, class: "control-label"
- .col-sm-10
- %p.light
- Paste a machine public key here. Read more about how to generate it
- = link_to "here", help_page_path("ssh/README")
- = f.text_area :key, class: "form-control thin_area", rows: 5
- .form-group
- .control-label
- .col-sm-10
- = f.label :can_push do
- = f.check_box :can_push
- %strong Write access allowed
- %p.light.append-bottom-0
- Allow this key to push to repository as well? (Default only allows pull access.)
-
+ = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
.form-actions
- = f.submit 'Create', class: "btn-create btn"
- = link_to "Cancel", admin_deploy_keys_path, class: "btn btn-cancel"
-
+ = f.submit 'Create', class: 'btn-create btn'
+ = link_to 'Cancel', admin_deploy_keys_path, class: 'btn btn-cancel'
diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml
index 6a208d76a38..f16f59623f7 100644
--- a/app/views/admin/health_check/show.html.haml
+++ b/app/views/admin/health_check/show.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
- page_title "Health Check"
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
%h3.page-title
@@ -10,30 +10,20 @@
%p
Access token is
%code#health-check-token= current_application_settings.health_check_access_token
- = button_to reset_health_check_token_admin_application_settings_path,
- method: :put, class: 'btn btn-default',
- data: { confirm: 'Are you sure you want to reset the health check token?' } do
- = icon('spinner')
- Reset health check access token
+ .prepend-top-10
+ = button_to "Reset health check access token", reset_health_check_token_admin_application_settings_path,
+ method: :put, class: 'btn btn-default',
+ data: { confirm: 'Are you sure you want to reset the health check token?' }
%p.light
- Health information can be retrieved as plain text, JSON, or XML using:
+ Health information can be retrieved from the following endpoints. More information is available
+ = link_to 'here', help_page_path('user/admin_area/monitoring/health_check')
%ul
%li
- %code= health_check_url(token: current_application_settings.health_check_access_token)
+ %code= readiness_url(token: current_application_settings.health_check_access_token)
%li
- %code= health_check_url(token: current_application_settings.health_check_access_token, format: :json)
+ %code= liveness_url(token: current_application_settings.health_check_access_token)
%li
- %code= health_check_url(token: current_application_settings.health_check_access_token, format: :xml)
-
- %p.light
- You can also ask for the status of specific services:
- %ul
- %li
- %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :cache)
- %li
- %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :database)
- %li
- %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :migrations)
+ %code= metrics_url(token: current_application_settings.health_check_access_token)
%hr
.panel.panel-default
diff --git a/app/views/admin/hook_logs/_index.html.haml b/app/views/admin/hook_logs/_index.html.haml
new file mode 100644
index 00000000000..7dd9943190f
--- /dev/null
+++ b/app/views/admin/hook_logs/_index.html.haml
@@ -0,0 +1,37 @@
+.row.prepend-top-default.append-bottom-default
+ .col-lg-3
+ %h4.prepend-top-0
+ Recent Deliveries
+ %p When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong.
+ .col-lg-9
+ - if hook_logs.any?
+ %table.table
+ %thead
+ %tr
+ %th Status
+ %th Trigger
+ %th URL
+ %th Elapsed time
+ %th Request time
+ %th
+ - hook_logs.each do |hook_log|
+ %tr
+ %td
+ = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log }
+ %td.hidden-xs
+ %span.label.label-gray.deploy-project-label
+ = hook_log.trigger.singularize.titleize
+ %td
+ = truncate(hook_log.url, length: 50)
+ %td.light
+ #{number_with_precision(hook_log.execution_duration, precision: 2)} ms
+ %td.light
+ = time_ago_with_tooltip(hook_log.created_at)
+ %td
+ = link_to 'View details', admin_hook_hook_log_path(hook, hook_log)
+
+ = paginate hook_logs, theme: 'gitlab'
+
+ - else
+ .settings-message.text-center
+ You don't have any webhooks deliveries
diff --git a/app/views/admin/hook_logs/show.html.haml b/app/views/admin/hook_logs/show.html.haml
new file mode 100644
index 00000000000..56127bacda2
--- /dev/null
+++ b/app/views/admin/hook_logs/show.html.haml
@@ -0,0 +1,10 @@
+- page_title 'Request details'
+%h3.page-title
+ Request details
+
+%hr
+
+= link_to 'Resend Request', retry_admin_hook_hook_log_path(@hook, @hook_log), class: "btn btn-default pull-right prepend-left-10"
+
+= render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log }
+
diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml
index 6217d5fb135..645005c6deb 100644
--- a/app/views/admin/hooks/_form.html.haml
+++ b/app/views/admin/hooks/_form.html.haml
@@ -18,19 +18,26 @@
or adding ssh key. But you can also enable extra triggers like Push events.
.prepend-top-default
+ = form.check_box :repository_update_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :repository_update_events, class: 'list-label' do
+ %strong Repository update events
+ %p.light
+ This URL will be triggered when repository is updated
+ %div
= form.check_box :push_events, class: 'pull-left'
.prepend-left-20
= form.label :push_events, class: 'list-label' do
%strong Push events
%p.light
- This url will be triggered by a push to the repository
+ This URL will be triggered for each branch updated to the repository
%div
= form.check_box :tag_push_events, class: 'pull-left'
.prepend-left-20
= form.label :tag_push_events, class: 'list-label' do
%strong Tag push events
%p.light
- This url will be triggered when a new tag is pushed to the repository
+ This URL will be triggered when a new tag is pushed to the repository
.form-group
= form.label :enable_ssl_verification, 'SSL verification', class: 'control-label checkbox'
.col-sm-10
diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
index 0777f5e2629..0e35a1905bf 100644
--- a/app/views/admin/hooks/edit.html.haml
+++ b/app/views/admin/hooks/edit.html.haml
@@ -12,3 +12,9 @@
= render partial: 'form', locals: { form: f, hook: @hook }
.form-actions
= f.submit 'Save changes', class: 'btn btn-create'
+ = link_to 'Test hook', test_admin_hook_path(@hook), class: 'btn btn-default'
+ = link_to 'Remove', admin_hook_path(@hook), method: :delete, class: 'btn btn-remove pull-right', data: { confirm: 'Are you sure?' }
+
+%hr
+
+= render partial: 'admin/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs }
diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml
index 71117758921..e92b8bc39f4 100644
--- a/app/views/admin/hooks/index.html.haml
+++ b/app/views/admin/hooks/index.html.haml
@@ -27,7 +27,7 @@
= link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm'
.monospace= hook.url
%div
- - %w(push_events tag_push_events issues_events note_events merge_requests_events build_events).each do |trigger|
+ - %w(repository_update_events push_events tag_push_events issues_events note_events merge_requests_events job_events).each do |trigger|
- if hook.send(trigger)
%span.label.label-gray= trigger.titleize
%span.label.label-gray SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'}
diff --git a/app/views/admin/builds/index.html.haml b/app/views/admin/jobs/index.html.haml
index 66d633119c2..09be17f07be 100644
--- a/app/views/admin/builds/index.html.haml
+++ b/app/views/admin/jobs/index.html.haml
@@ -4,15 +4,15 @@
%div{ class: container_class }
.top-area
- - build_path_proc = ->(scope) { admin_builds_path(scope: scope) }
+ - build_path_proc = ->(scope) { admin_jobs_path(scope: scope) }
= render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope
.nav-controls
- if @all_builds.running_or_pending.any?
- = link_to 'Cancel all', cancel_all_admin_builds_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
+ = link_to 'Cancel all', cancel_all_admin_jobs_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
.row-content-block.second-block
#{(@scope || 'all').capitalize} jobs
%ul.content-list.builds-content-list.admin-builds-table
- = render "projects/builds/table", builds: @builds, admin: true
+ = render "projects/jobs/table", builds: @builds, admin: true
diff --git a/app/views/admin/logs/show.html.haml b/app/views/admin/logs/show.html.haml
index 5e585ce789b..487f1cf5c4f 100644
--- a/app/views/admin/logs/show.html.haml
+++ b/app/views/admin/logs/show.html.haml
@@ -3,7 +3,7 @@
- loggers = [Gitlab::GitLogger, Gitlab::AppLogger,
Gitlab::EnvironmentLogger, Gitlab::SidekiqLogger,
Gitlab::RepositoryCheckLogger]
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
%ul.nav-links.log-tabs
diff --git a/app/views/admin/background_jobs/_head.html.haml b/app/views/admin/monitoring/_head.html.haml
index b3530915068..901e30275fd 100644
--- a/app/views/admin/background_jobs/_head.html.haml
+++ b/app/views/admin/monitoring/_head.html.haml
@@ -3,6 +3,10 @@
= render 'shared/nav_scroll'
.nav-links.sub-nav.scrolling-tabs
%ul{ class: (container_class) }
+ = nav_link(controller: :conversational_development_index) do
+ = link_to admin_conversational_development_index_path, title: 'ConvDev Index' do
+ %span
+ ConvDev Index
= nav_link(controller: :system_info) do
= link_to admin_system_info_path, title: 'System Info' do
%span
diff --git a/app/views/admin/requests_profiles/index.html.haml b/app/views/admin/requests_profiles/index.html.haml
index ae918086a57..b7db18b2d32 100644
--- a/app/views/admin/requests_profiles/index.html.haml
+++ b/app/views/admin/requests_profiles/index.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
- page_title 'Requests Profiles'
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
%h3.page-title
@@ -20,7 +20,7 @@
%ul.content-list
- profiles.each do |profile|
%li
- = link_to profile.time.to_s(:long), admin_requests_profile_path(profile), data: {no_turbolink: true}
+ = link_to profile.time.to_s(:long), admin_requests_profile_path(profile)
- else
%p
No profiles found
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index f118804cace..e242e851b4d 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -17,12 +17,10 @@
.pull-left
%p
You can reset runners registration token by pressing a button below.
- %p
- = button_to reset_runners_token_admin_application_settings_path,
+ .prepend-top-10
+ = button_to "Reset runners registration token", reset_runners_token_admin_application_settings_path,
method: :put, class: 'btn btn-default',
- data: { confirm: 'Are you sure you want to reset registration token?' } do
- = icon('spinner')
- Reset runners registration token
+ data: { confirm: 'Are you sure you want to reset registration token?' }
.bs-callout
%p
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index dc4116e1ce0..801430e525e 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -85,7 +85,7 @@
%tr.build
%td.id
- if project
- = link_to namespace_project_build_path(project.namespace, project, build) do
+ = link_to namespace_project_job_path(project.namespace, project, build) do
%strong ##{build.id}
- else
%strong ##{build.id}
diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml
index 2e5f120c4e4..fd0281e4961 100644
--- a/app/views/admin/system_info/show.html.haml
+++ b/app/views/admin/system_info/show.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
- page_title "System Info"
-= render 'admin/background_jobs/head'
+= render 'admin/monitoring/head'
%div{ class: container_class }
.prepend-top-default
@@ -31,3 +31,8 @@
%h1 #{number_to_human_size(disk[:bytes_used])} / #{number_to_human_size(disk[:bytes_total])}
%p= disk[:disk_name]
%p= disk[:mount_path]
+ .col-sm-4
+ .light-well
+ %h4 Uptime
+ .data
+ %h1= time_ago_with_tooltip(Rails.application.config.booted_at)
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index 8862455688f..4cf4a57ba18 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -34,9 +34,15 @@
- if user.access_locked?
%li
= link_to 'Unlock', unlock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success', data: { confirm: 'Are you sure?' }
- - if user.can_be_removed? && can?(current_user, :destroy_user, @user)
+ - if can?(current_user, :destroy_user, user)
%li.divider
+ - if user.can_be_removed?
+ %li
+ = link_to 'Remove user', admin_user_path(user),
+ data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" },
+ method: :delete
%li
- = link_to 'Delete user', [:admin, user], data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and groups linked to this user will also be removed! Consider cancelling this deletion and blocking the user instead. Are you sure?" },
- class: 'btn btn-remove btn-block',
- method: :delete
+ = link_to 'Remove user and contributions', admin_user_path(user, hard_delete: true),
+ data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and comments authored by this user, and groups owned solely by them, will also be removed! Are you sure?" },
+ class: 'btn btn-remove btn-block',
+ method: :delete
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index 89d0bbb7126..b556ff056c0 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -177,7 +177,7 @@
%p Deleting a user has the following effects:
= 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"
+ = link_to 'Remove user', admin_user_path(@user), data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove"
- else
- if @user.solo_owned_groups.present?
%p
@@ -188,3 +188,22 @@
- else
%p
You don't have access to delete this user.
+
+ .panel.panel-danger
+ .panel-heading
+ Remove user and contributions
+ .panel-body
+ - if can?(current_user, :destroy_user, @user)
+ %p
+ This option deletes the user and any contributions that
+ would usually be moved to the
+ = succeed "." do
+ = link_to "system ghost user", help_page_path("user/profile/account/delete_account")
+ As well as the user's personal projects, groups owned solely by
+ the user, and projects in them, will also be removed. Commits
+ to other projects are unaffected.
+ %br
+ = link_to 'Remove user and contributions', admin_user_path(@user, hard_delete: true), data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove"
+ - else
+ %p
+ You don't have access to delete this user.
diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml
index 9aabfb49a29..5f07d2720c2 100644
--- a/app/views/award_emoji/_awards_block.html.haml
+++ b/app/views/award_emoji/_awards_block.html.haml
@@ -12,9 +12,9 @@
- if current_user
.award-menu-holder.js-award-holder
%button.btn.award-control.has-tooltip.js-add-award{ type: 'button',
- 'aria-label': 'Add emoji',
+ 'aria-label': 'Add reaction',
class: ("js-user-authored" if user_authored),
- data: { title: 'Add emoji', placement: "bottom" } }
+ data: { title: 'Add reaction', placement: "bottom" } }
%span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face')
%span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley')
%span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile')
diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml
index 89d991abe54..a676eba2aee 100644
--- a/app/views/dashboard/_activities.html.haml
+++ b/app/views/dashboard/_activities.html.haml
@@ -1,7 +1,4 @@
-.hidden-xs
- = render "events/event_last_push", event: @last_push
-
-.nav-block
+.nav-block.activities
.controls
= link_to dashboard_projects_path(rss_url_options), class: 'btn rss-btn has-tooltip', title: 'Subscribe' do
%i.fa.fa-rss
diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml
index 190ad4b40a5..f893c3e1675 100644
--- a/app/views/dashboard/activity.html.haml
+++ b/app/views/dashboard/activity.html.haml
@@ -1,10 +1,16 @@
+- @no_container = true
+
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity")
- page_title "Activity"
- header_title "Activity", activity_dashboard_path
-= render 'dashboard/activity_head'
+.hidden-xs
+ = render "projects/last_push"
+
+%div{ class: container_class }
+ = render 'dashboard/activity_head'
-%section.activities
- = render 'activities'
+ %section.activities
+ = render 'activities'
diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml
index faa68468043..d6b46dee0e4 100644
--- a/app/views/dashboard/issues.html.haml
+++ b/app/views/dashboard/issues.html.haml
@@ -8,7 +8,7 @@
.nav-controls
= link_to params.merge(rss_url_options), class: 'btn has-tooltip', title: 'Subscribe' do
= icon('rss')
- = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue"
+ = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", with_feature_enabled: 'issues'
= render 'shared/issuable/filter', type: :issues
= render 'shared/issues'
diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml
index 12966c01950..6f6afe161d1 100644
--- a/app/views/dashboard/merge_requests.html.haml
+++ b/app/views/dashboard/merge_requests.html.haml
@@ -4,7 +4,7 @@
.top-area
= render 'shared/issuable/nav', type: :merge_requests
.nav-controls
- = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request"
+ = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", with_feature_enabled: 'merge_requests'
= render 'shared/issuable/filter', type: :merge_requests
= render 'shared/merge_requests'
diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml
index 596499230f9..5e63a61e21b 100644
--- a/app/views/dashboard/projects/index.html.haml
+++ b/app/views/dashboard/projects/index.html.haml
@@ -1,19 +1,21 @@
+- @no_container = true
+
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity")
- page_title "Projects"
- header_title "Projects", dashboard_projects_path
-- unless show_user_callout?
- = render 'shared/user_callout'
+= render "projects/last_push"
-- if @projects.any? || params[:name]
- = render 'dashboard/projects_head'
+%div{ class: container_class }
+ - if show_callout?('user_callout_dismissed')
+ = render 'shared/user_callout'
-- if @last_push
- = render "events/event_last_push", event: @last_push
+ - if @projects.any? || params[:name]
+ = render 'dashboard/projects_head'
-- if @projects.any? || params[:name]
- = render 'projects'
-- else
- = render "zero_authorized_projects"
+ - if @projects.any? || params[:name]
+ = render 'projects'
+ - else
+ = render "zero_authorized_projects"
diff --git a/app/views/dashboard/projects/starred.html.haml b/app/views/dashboard/projects/starred.html.haml
index 162ae153b1c..99efe9c9b86 100644
--- a/app/views/dashboard/projects/starred.html.haml
+++ b/app/views/dashboard/projects/starred.html.haml
@@ -1,13 +1,15 @@
+- @no_container = true
+
- page_title "Starred Projects"
- header_title "Projects", dashboard_projects_path
-= render 'dashboard/projects_head'
+= render "projects/last_push"
-- if @last_push
- = render "events/event_last_push", event: @last_push
+%div{ class: container_class }
+ = render 'dashboard/projects_head'
-- if @projects.any? || params[:filter_projects]
- = render 'projects'
-- else
- %h3 You don't have starred projects yet
- %p.slead Visit project page and press on star icon and it will appear on this page.
+ - if @projects.any? || params[:filter_projects]
+ = render 'projects'
+ - else
+ %h3 You don't have starred projects yet
+ %p.slead Visit project page and press on star icon and it will appear on this page.
diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml
index 5e189e6dc54..eb0e6701627 100644
--- a/app/views/devise/passwords/edit.html.haml
+++ b/app/views/devise/passwords/edit.html.haml
@@ -6,10 +6,10 @@
= devise_error_messages!
= f.hidden_field :reset_password_token
.form-group
- = f.label 'New password', for: :password
+ = f.label 'New password', for: "user_password"
= f.password_field :password, class: "form-control top", required: true, title: 'This field is required'
.form-group
- = f.label 'Confirm new password', for: :password_confirmation
+ = f.label 'Confirm new password', for: "user_password_confirmation"
= f.password_field :password_confirmation, class: "form-control bottom", title: 'This field is required', required: true
.clearfix
= f.submit "Change your password", class: "btn btn-primary"
diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml
index 21c751a23f8..4095f30c369 100644
--- a/app/views/devise/sessions/_new_base.html.haml
+++ b/app/views/devise/sessions/_new_base.html.haml
@@ -1,6 +1,6 @@
= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive'}) do |f|
.form-group
- = f.label "Username or email", for: :login
+ = f.label "Username or email", for: "user_login"
= f.text_field :login, class: "form-control top", autofocus: "autofocus", autocapitalize: "off", autocorrect: "off", required: true, title: "This field is required."
.form-group
= f.label :password
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index a2f6a7ab1cb..d696577278d 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -8,7 +8,7 @@
= f.text_field :name, class: "form-control top", required: true, title: "This field is required."
.username.form-group
= f.label :username
- = f.text_field :username, class: "form-control middle", pattern: Gitlab::Regex::NAMESPACE_REGEX_STR_JS, required: true, title: 'Please create a username with only alphanumeric characters.'
+ = f.text_field :username, class: "form-control middle", pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: 'Please create a username with only alphanumeric characters.'
%p.validation-error.hide Username is already taken.
%p.validation-success.hide Username is available.
%p.validation-pending.hide Checking username availability...
diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml
index 78c5b0c1dda..70042dee20f 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_path(discussion)
+ = render "projects/diffs/file_header", diff_file: diff_file, url: discussion_path(discussion), show_toggle: false
.diff-content.code.js-syntax-highlight
%table
diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml
index 38e85168f40..578e751ab47 100644
--- a/app/views/discussions/_discussion.html.haml
+++ b/app/views/discussions/_discussion.html.haml
@@ -26,16 +26,15 @@
- commit = discussion.noteable
- if commit
commit
- = link_to commit.short_id, url, class: 'monospace'
+ = link_to commit.short_id, url, class: 'commit-sha'
- else
a deleted commit
- elsif discussion.diff_discussion?
on
= conditional_link_to url.present?, url do
- - if discussion.active?
- the diff
- - else
- an outdated diff
+ - unless discussion.active?
+ an old version of
+ the diff
= time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago")
= render "discussions/headline", discussion: discussion
diff --git a/app/views/discussions/_jump_to_next.html.haml b/app/views/discussions/_jump_to_next.html.haml
index 69bd416c4de..3db509f24a5 100644
--- a/app/views/discussions/_jump_to_next.html.haml
+++ b/app/views/discussions/_jump_to_next.html.haml
@@ -3,7 +3,7 @@
%jump-to-discussion{ "inline-template" => true, ":discussion-id" => "'#{discussion.try(:id)}'" }
.btn-group{ role: "group", "v-show" => "!allResolved", "v-if" => "showButton" }
%button.btn.btn-default.discussion-next-btn.has-tooltip{ "@click" => "jumpToNextUnresolvedDiscussion",
- title: "Jump to next unresolved discussion",
- "aria-label" => "Jump to next unresolved discussion",
+ ":title" => "buttonText",
+ ":aria-label" => "buttonText",
data: { container: "body" } }
= custom_icon("next_discussion")
diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml
index 7ba3f3f6c42..db5ab939948 100644
--- a/app/views/discussions/_notes.html.haml
+++ b/app/views/discussions/_notes.html.haml
@@ -1,10 +1,11 @@
.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
+ .flash-container
+
+ .discussion-reply-holder
+ - if can_create_note?
- if discussion.potentially_resolvable?
- line_type = local_assigns.fetch(:line_type, nil)
@@ -19,3 +20,10 @@
= render "discussions/jump_to_next", discussion: discussion
- else
= link_to_reply_discussion(discussion)
+ - elsif !current_user
+ .disabled-comment.text-center
+ Please
+ = link_to "register", new_session_path(:user, redirect_to_referer: 'yes')
+ or
+ = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes')
+ to reply
diff --git a/app/views/events/_commit.html.haml b/app/views/events/_commit.html.haml
index 1bc9f604438..3c64f1be5ff 100644
--- a/app/views/events/_commit.html.haml
+++ b/app/views/events/_commit.html.haml
@@ -1,5 +1,5 @@
%li.commit
.commit-row-title
- = link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit_short_id", alt: '', title: truncate_sha(commit[:id])
+ = link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit-sha", alt: '', title: truncate_sha(commit[:id])
&middot;
= markdown event_commit_title(commit[:message]), project: project, pipeline: :single_line, author: event.author
diff --git a/app/views/events/_event_last_push.html.haml b/app/views/events/_event_last_push.html.haml
deleted file mode 100644
index 1584695a62b..00000000000
--- a/app/views/events/_event_last_push.html.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-- if show_last_push_widget?(event)
- .row-content-block.clear-block.last-push-widget
- .event-last-push
- .event-last-push-text
- %span You pushed to
- = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: h(event.project.name) do
- %strong= event.ref_name
- %span at
- %strong= link_to_project event.project
- #{time_ago_with_tooltip(event.created_at)}
-
- .pull-right
- = link_to new_mr_path_from_push_event(event), title: "New merge request", class: "btn btn-info btn-sm" do
- Create merge request
diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml
index c0943100ae3..769ac655d0a 100644
--- a/app/views/events/event/_push.html.haml
+++ b/app/views/events/event/_push.html.haml
@@ -7,7 +7,7 @@
%span.pushed #{event.action_name} #{event.ref_type}
%strong
- commits_link = namespace_project_commits_path(project.namespace, project, event.ref_name)
- = link_to_if project.repository.branch_exists?(event.ref_name), event.ref_name, commits_link
+ = link_to_if project.repository.branch_exists?(event.ref_name), event.ref_name, commits_link, class: 'ref-name'
= render "events/event_scope", event: event
diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml
index d7851c79990..fd6e7111f38 100644
--- a/app/views/groups/_activities.html.haml
+++ b/app/views/groups/_activities.html.haml
@@ -1,6 +1,3 @@
-.hidden-xs
- = render "events/event_last_push", event: @last_push
-
.nav-block
.controls
= link_to group_path(@group, rss_url_options), class: 'btn rss-btn has-tooltip' , title: 'Subscribe' do
diff --git a/app/views/groups/_head.html.haml b/app/views/groups/_head.html.haml
index 873504099d4..0f63774fb9b 100644
--- a/app/views/groups/_head.html.haml
+++ b/app/views/groups/_head.html.haml
@@ -12,3 +12,6 @@
= link_to activity_group_path(@group), title: 'Activity' do
%span
Activity
+
+.hidden-xs
+ = render "projects/last_push"
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 18997baa998..80a8ba4a755 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -6,7 +6,6 @@
= render 'groups/head'
= render 'groups/home_panel'
-
.groups-header{ class: container_class }
.top-area
= render 'groups/show_nav'
diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml
index 9999a4362c6..c52a515226e 100644
--- a/app/views/import/fogbugz/new_user_map.html.haml
+++ b/app/views/import/fogbugz/new_user_map.html.haml
@@ -46,6 +46,3 @@
.form-actions
= submit_tag 'Continue to the next step', class: 'btn btn-create'
-
-:javascript
- new UsersSelect();
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 9e354987401..1ef0d524dbb 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -33,6 +33,7 @@
= webpack_bundle_tag "runtime"
= webpack_bundle_tag "common"
+ = webpack_bundle_tag "locale"
= webpack_bundle_tag "main"
= webpack_bundle_tag "raven" if current_application_settings.clientside_sentry_enabled
= webpack_bundle_tag "test" if Rails.env.test?
diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml
index 769f6fb0151..6caaba240bb 100644
--- a/app/views/layouts/_init_auto_complete.html.haml
+++ b/app/views/layouts/_init_auto_complete.html.haml
@@ -3,6 +3,7 @@
- if project
:javascript
+ gl.GfmAutoComplete = gl.GfmAutoComplete || {};
gl.GfmAutoComplete.dataSources = {
members: "#{members_namespace_project_autocomplete_sources_path(project.namespace, project, type: noteable_type, type_id: params[:id])}",
issues: "#{issues_namespace_project_autocomplete_sources_path(project.namespace, project)}",
@@ -11,5 +12,3 @@
milestones: "#{milestones_namespace_project_autocomplete_sources_path(project.namespace, project)}",
commands: "#{commands_namespace_project_autocomplete_sources_path(project.namespace, project, type: noteable_type, type_id: params[:id])}"
};
-
- gl.GfmAutoComplete.setup();
diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml
index 0e64ebd71b8..b689991bb6d 100644
--- a/app/views/layouts/_search.html.haml
+++ b/app/views/layouts/_search.html.haml
@@ -13,7 +13,7 @@
.location-badge= label
.search-input-wrap
.dropdown{ data: { url: search_autocomplete_path } }
- = search_field_tag 'search', nil, placeholder: 'Search', class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', spellcheck: false, tabindex: '1', autocomplete: 'off', data: { toggle: 'dropdown', issues_path: issues_dashboard_url, mr_path: merge_requests_dashboard_url }
+ = search_field_tag 'search', nil, placeholder: 'Search', class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', spellcheck: false, tabindex: '1', autocomplete: 'off', data: { toggle: 'dropdown', issues_path: issues_dashboard_url, mr_path: merge_requests_dashboard_url }, aria: { label: 'Search' }
.dropdown-menu.dropdown-select
= dropdown_content do
%ul
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 7e011ac3e75..03688e9ff21 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -2,8 +2,8 @@
%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}" } }
+ = render "layouts/init_auto_complete" if @gfm_form
= render "layouts/header/default", title: header_title
= render 'layouts/page', sidebar: sidebar, nav: nav
= yield :scripts_body
- = render "layouts/init_auto_complete" if @gfm_form
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 659d548df18..249253f4906 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -1,4 +1,5 @@
%header.navbar.navbar-gitlab{ class: nav_header_class }
+ .navbar-border
%a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content
.container-fluid
.header-content
@@ -35,10 +36,7 @@
%li
= link_to admin_root_path, title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('wrench fw')
- - if current_user.can_create_project?
- %li
- = link_to new_project_path, title: 'New project', aria: { label: "New project" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = icon('plus fw')
+ = render 'layouts/header/new_dropdown'
- if Gitlab::Sherlock.enabled?
%li
= link_to sherlock_transactions_path, title: 'Sherlock Transactions',
@@ -73,12 +71,12 @@
@#{current_user.username}
%li.divider
%li
- = link_to "Profile", current_user, class: 'profile-link', aria: { label: "Profile" }, data: { user: current_user.username }
+ = link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username }
%li
- = link_to "Settings", profile_path, aria: { label: "Settings" }
+ = link_to "Settings", profile_path
%li.divider
%li
- = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link", aria: { label: "Sign out" }
+ = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link"
- else
%li
%div
diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml
new file mode 100644
index 00000000000..c7302414386
--- /dev/null
+++ b/app/views/layouts/header/_new_dropdown.haml
@@ -0,0 +1,45 @@
+%li.header-new.dropdown
+ = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip", title: "New...", ref: 'tooltip', aria: { label: "New..." }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body' } do
+ = icon('plus fw')
+ = icon('caret-down')
+ .dropdown-menu-nav.dropdown-menu-align-right
+ %ul
+ - if @group
+ - create_group_project = can?(current_user, :create_projects, @group)
+ - create_group_subgroup = can?(current_user, :create_subgroup, @group)
+ - if create_group_project || create_group_subgroup
+ %li.dropdown-bold-header This group
+ - if create_group_project
+ %li.header-new-group-project
+ = link_to 'New project', new_project_path(namespace_id: @group.id)
+ - if create_group_subgroup
+ %li
+ = link_to 'New subgroup', new_group_path(parent_id: @group.id)
+ %li.divider
+ %li.dropdown-bold-header GitLab
+
+ - if @project && @project.persisted?
+ - create_project_issue = can?(current_user, :create_issue, @project)
+ - merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
+ - create_project_snippet = can?(current_user, :create_project_snippet, @project)
+ - if create_project_issue || merge_project || create_project_snippet
+ %li.dropdown-bold-header This project
+ - if create_project_issue
+ %li
+ = link_to 'New issue', new_namespace_project_issue_path(@project.namespace, @project)
+ - if merge_project
+ %li
+ = link_to 'New merge request', new_namespace_project_merge_request_path(merge_project.namespace, merge_project)
+ - if create_project_snippet
+ %li.header-new-project-snippet
+ = link_to 'New snippet', new_namespace_project_snippet_path(@project.namespace, @project)
+ %li.divider
+ %li.dropdown-bold-header GitLab
+ - if current_user.can_create_project?
+ %li
+ = link_to 'New project', new_project_path
+ - if current_user.can_create_group?
+ %li
+ = link_to 'New group', new_group_path
+ %li
+ = link_to 'New snippet', new_snippet_path
diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml
index d068c895fa3..6df0adfd742 100644
--- a/app/views/layouts/nav/_admin.html.haml
+++ b/app/views/layouts/nav/_admin.html.haml
@@ -5,19 +5,19 @@
.fade-right
= icon('angle-right')
%ul.nav-links.scrolling-tabs
- = nav_link(controller: %w(dashboard admin projects users groups builds runners), html_options: {class: 'home'}) do
+ = nav_link(controller: %w(dashboard admin projects users groups builds runners cohorts), html_options: {class: 'home'}) do
= link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do
%span
Overview
- = nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles)) do
- = link_to admin_system_info_path, title: 'Monitoring' do
+ = nav_link(controller: %w(conversational_development_index system_info background_jobs logs health_check requests_profiles)) do
+ = link_to admin_conversational_development_index_path, title: 'Monitoring' do
%span
Monitoring
= nav_link(controller: :broadcast_messages) do
= link_to admin_broadcast_messages_path, title: 'Messages' do
%span
Messages
- = nav_link(controller: :hooks) do
+ = nav_link(controller: [:hooks, :hook_logs]) do
= link_to admin_hooks_path, title: 'Hooks' do
%span
System Hooks
diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml
index e06301bda14..ae1e1361f0f 100644
--- a/app/views/layouts/nav/_profile.html.haml
+++ b/app/views/layouts/nav/_profile.html.haml
@@ -48,6 +48,6 @@
%span
Preferences
= nav_link(path: 'profiles#audit_log') do
- = link_to audit_log_profile_path, title: 'Audit Log' do
+ = link_to audit_log_profile_path, title: 'Authentication log' do
%span
- Audit Log
+ Authentication log
diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml
index e4dfe0c8c08..29658da7792 100644
--- a/app/views/layouts/nav/_project.html.haml
+++ b/app/views/layouts/nav/_project.html.haml
@@ -92,7 +92,7 @@
-# Shortcut to Pipelines > Jobs
- if project_nav_tab? :builds
%li.hidden
- = link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
+ = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
Jobs
-# Shortcut to commits page
diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml
index 98b75cea03f..57971205e0e 100644
--- a/app/views/layouts/snippets.html.haml
+++ b/app/views/layouts/snippets.html.haml
@@ -1,9 +1,8 @@
- header_title "Snippets", snippets_path
- content_for :page_specific_javascripts do
- - if @snippet&.persisted? && current_user
+ - if @snippet && current_user
:javascript
- window.uploads_path = "#{upload_path('personal_snippet', @snippet)}";
- window.preview_markdown_path = "#{preview_markdown_snippet_path(@snippet)}";
+ window.uploads_path = "#{upload_path('personal_snippet', id: @snippet.id)}";
= render template: "layouts/application"
diff --git a/app/views/notify/links/ci/builds/_build.html.haml b/app/views/notify/links/ci/builds/_build.html.haml
index d35b3839171..644cf506eff 100644
--- a/app/views/notify/links/ci/builds/_build.html.haml
+++ b/app/views/notify/links/ci/builds/_build.html.haml
@@ -1,2 +1,2 @@
-%a{ href: pipeline_build_url(pipeline, build), style: "color:#3777b0;text-decoration:none;" }
+%a{ href: pipeline_job_url(pipeline, build), style: "color:#3777b0;text-decoration:none;" }
= build.name
diff --git a/app/views/notify/links/ci/builds/_build.text.erb b/app/views/notify/links/ci/builds/_build.text.erb
index 741c7f344c8..773ae8174e9 100644
--- a/app/views/notify/links/ci/builds/_build.text.erb
+++ b/app/views/notify/links/ci/builds/_build.text.erb
@@ -1 +1 @@
-Job #<%= build.id %> ( <%= pipeline_build_url(pipeline, build) %> )
+Job #<%= build.id %> ( <%= pipeline_job_url(pipeline, build) %> )
diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml
index 02eb7c8462c..546376aeed8 100644
--- a/app/views/notify/repository_push_email.html.haml
+++ b/app/views/notify/repository_push_email.html.haml
@@ -27,40 +27,38 @@
%h4 #{pluralize @message.diffs_count, "changed file"}:
%ul
- - @message.diffs.each do |diff|
+ - @message.diffs.each do |diff_file|
%li.file-stats
- %a{ href: "#{@message.target_url if @message.disable_diffs?}##{hexdigest(diff.file_path)}" }
- - if diff.deleted_file
+ %a{ href: "#{@message.target_url if @message.disable_diffs?}##{hexdigest(diff_file.file_path)}" }
+ - if diff_file.deleted_file?
%span.deleted-file
&minus;
- = diff.old_path
- - elsif diff.renamed_file
- = diff.old_path
+ = diff_file.old_path
+ - elsif diff_file.renamed_file?
+ = diff_file.old_path
&rarr;
- = diff.new_path
- - elsif diff.new_file
+ = diff_file.new_path
+ - elsif diff_file.new_file?
%span.new-file
&#43;
- = diff.new_path
+ = diff_file.new_path
- else
- = diff.new_path
+ = diff_file.new_path
- unless @message.disable_diffs?
- - diff_files = @message.diffs
-
- if @message.compare_timeout
%h5 The diff was not included because it is too large.
- else
%h4 Changes:
- - diff_files.each do |diff_file|
+ - @message.diffs.each do |diff_file|
- file_hash = hexdigest(diff_file.file_path)
%li{ id: file_hash }
%a{ href: @message.target_url + "##{file_hash}" }<
- - if diff_file.deleted_file
+ - if diff_file.deleted_file?
%strong<
= diff_file.old_path
deleted
- - elsif diff_file.renamed_file
+ - elsif diff_file.renamed_file?
%strong<
= diff_file.old_path
&rarr;
diff --git a/app/views/notify/repository_push_email.text.haml b/app/views/notify/repository_push_email.text.haml
index 5ac23aa3997..895d8807e47 100644
--- a/app/views/notify/repository_push_email.text.haml
+++ b/app/views/notify/repository_push_email.text.haml
@@ -15,15 +15,15 @@
\
#{pluralize @message.diffs_count, "changed file"}:
\
- - @message.diffs.each do |diff|
- - if diff.deleted_file
- \- − #{diff.old_path}
- - elsif diff.renamed_file
- \- #{diff.old_path} → #{diff.new_path}
- - elsif diff.new_file
- \- + #{diff.new_path}
+ - @message.diffs.each do |diff_file|
+ - if diff_file.deleted_file?
+ \- − #{diff_file.old_path}
+ - elsif diff_file.renamed_file?
+ \- #{diff_file.old_path} → #{diff_file.new_path}
+ - elsif diff_file.new_file?
+ \- + #{diff_file.new_path}
- else
- \- #{diff.new_path}
+ \- #{diff_file.new_path}
- unless @message.disable_diffs?
- if @message.compare_timeout
\
@@ -36,9 +36,9 @@
- @message.diffs.each do |diff_file|
\
\=====================================
- - if diff_file.deleted_file
+ - if diff_file.deleted_file?
#{diff_file.old_path} deleted
- - elsif diff_file.renamed_file
+ - elsif diff_file.renamed_file?
#{diff_file.old_path} → #{diff_file.new_path}
- else
= diff_file.new_path
diff --git a/app/views/profiles/_event_table.html.haml b/app/views/profiles/_event_table.html.haml
index 879fc170f92..d0ad90ac6cc 100644
--- a/app/views/profiles/_event_table.html.haml
+++ b/app/views/profiles/_event_table.html.haml
@@ -9,7 +9,6 @@
Signed in with
= event.details[:with]
authentication
- %span.pull-right
- #{time_ago_in_words event.created_at} ago
+ %span.pull-right= time_ago_with_tooltip(event.created_at)
= paginate events, theme: "gitlab"
diff --git a/app/views/profiles/accounts/_reset_token.html.haml b/app/views/profiles/accounts/_reset_token.html.haml
new file mode 100644
index 00000000000..c31a4a8ecd4
--- /dev/null
+++ b/app/views/profiles/accounts/_reset_token.html.haml
@@ -0,0 +1,11 @@
+- name = label.parameterize
+- attribute = name.underscore
+
+.reset-action
+ %p.cgray
+ = label_tag name, label, class: "label-light"
+ = text_field_tag name, current_user.send(attribute), class: 'form-control', readonly: true, onclick: 'this.select()'
+ %p.help-block
+ = help_text
+ .prepend-top-default
+ = link_to button_label, [:reset, attribute, :profile], method: :put, data: { confirm: 'Are you sure?' }, class: 'btn btn-default private-token'
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index 73f33e69d68..a319b18e507 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -8,35 +8,17 @@
.row.prepend-top-default
.col-lg-3.profile-settings-sidebar
%h4.prepend-top-0
- = incoming_email_token_enabled? ? "Private Tokens" : "Private Token"
+ Private Tokens
%p
- Keep
- = incoming_email_token_enabled? ? "these tokens" : "this token"
- secret, anyone with access to them can interact with GitLab as if they were you.
+ Keep these tokens secret, anyone with access to them can interact with
+ GitLab as if they were you.
.col-lg-9.private-tokens-reset
- .reset-action
- %p.cgray
- - if current_user.private_token
- = label_tag "private-token", "Private token", class: "label-light"
- = text_field_tag "private-token", current_user.private_token, class: "form-control", readonly: true, onclick: "this.select()"
- - else
- %span You don't have one yet. Click generate to fix it.
- %p.help-block
- Your private token is used to access the API and Atom feeds without username/password authentication.
- .prepend-top-default
- - if current_user.private_token
- = link_to 'Reset private token', reset_private_token_profile_path, method: :put, data: { confirm: "Are you sure?" }, class: "btn btn-default private-token"
- - else
- = f.submit 'Generate', class: "btn btn-default"
+ = render partial: 'reset_token', locals: { label: 'Private token', button_label: 'Reset private token', help_text: 'Your private token is used to access the API and Atom feeds without username/password authentication.' }
+
+ = render partial: 'reset_token', locals: { label: 'RSS token', button_label: 'Reset RSS token', help_text: 'Your RSS token is used to create urls for personalized RSS feeds.' }
+
- if incoming_email_token_enabled?
- .reset-action
- %p.cgray
- = label_tag "incoming-email-token", "Incoming Email Token", class: 'label-light'
- = text_field_tag "incoming-email-token", current_user.incoming_email_token, class: "form-control", readonly: true, onclick: "this.select()"
- %p.help-block
- Your incoming email token is used to create new issues by email, and is included in your project-specific email addresses.
- .prepend-top-default
- = link_to 'Reset incoming email token', reset_incoming_email_token_profile_path, method: :put, data: { confirm: "Are you sure?" }, class: "btn btn-default incoming-email-token"
+ = render partial: 'reset_token', locals: { label: 'Incoming email token', button_label: 'Reset incoming email token', help_text: 'Your incoming email token is used to create new issues by email, and is included in your project-specific email addresses.' }
%hr
.row.prepend-top-default
diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml
index 9fe86e6b291..a24b7fd101d 100644
--- a/app/views/profiles/audit_log.html.haml
+++ b/app/views/profiles/audit_log.html.haml
@@ -1,4 +1,4 @@
-- page_title "Audit Log"
+- page_title "Authentication log"
= render 'profiles/head'
.row.prepend-top-default
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 99690e6b98a..0ff19b3eab1 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -37,9 +37,9 @@
= f.select :dashboard, dashboard_choices, {}, class: 'form-control'
.form-group
= f.label :project_view, class: 'label-light' do
- Project view
+ Project home page content
= f.select :project_view, project_view_choices, {}, class: 'form-control'
.help-block
- Choose what content you want to see on a project's home page.
+ Choose what content you want to see on a project’s home page
.form-group
= f.submit 'Save changes', class: 'btn btn-save'
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 4a1438aa68e..fcfd350f0da 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -49,10 +49,10 @@
.form-group
= f.label :email, class: "label-light"
- - if @user.ldap_user? && @user.ldap_email?
+ - if @user.external_email?
= f.text_field :email, class: "form-control", required: true, readonly: true
%span.help-block.light
- Your email address was automatically set based on the LDAP server.
+ Your email address was automatically set based on your #{email_provider_label} account.
- else
- if @user.temp_oauth_email?
= f.text_field :email, class: "form-control", required: true, value: nil
diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml
index aa0cb3e1a50..10f581d751b 100644
--- a/app/views/projects/_activity.html.haml
+++ b/app/views/projects/_activity.html.haml
@@ -1,7 +1,5 @@
-- @no_container = true
-
%div{ class: container_class }
- .nav-block.activity-filter-block
+ .nav-block.activity-filter-block.activities
.controls
= link_to namespace_project_path(@project.namespace, @project, rss_url_options), title: "Subscribe", class: 'btn rss-btn has-tooltip' do
= icon('rss')
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 96c2fa87f45..426085b3e1c 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -1,6 +1,14 @@
+- commit = local_assigns.fetch(:commit) { @repository.commit }
+- ref = local_assigns.fetch(:ref) { current_ref }
+- project = local_assigns.fetch(:project) { @project }
#tree-holder.tree-holder.clearfix
.nav-block
= render 'projects/tree/tree_header', tree: @tree
- = render 'projects/tree/tree_content', tree: @tree
+ - if commit
+ .info-well.hidden-xs.project-last-commit.append-bottom-default
+ .well-segment
+ %ul.blob-commit-info
+ = render 'projects/commits/commit', commit: commit, ref: ref, project: project
+ = render 'projects/tree/tree_content', tree: @tree
diff --git a/app/views/projects/_find_file_link.html.haml b/app/views/projects/_find_file_link.html.haml
index 3feb11645a0..c748ccf65e6 100644
--- a/app/views/projects/_find_file_link.html.haml
+++ b/app/views/projects/_find_file_link.html.haml
@@ -1,3 +1,3 @@
= link_to namespace_project_find_file_path(@project.namespace, @project, @ref), class: 'btn btn-grouped shortcuts-find-file', rel: 'nofollow' do
= icon('search')
- %span Find file
+ %span= _('Find file')
diff --git a/app/views/projects/_head.html.haml b/app/views/projects/_head.html.haml
index db08b77c8e0..dba84838a52 100644
--- a/app/views/projects/_head.html.haml
+++ b/app/views/projects/_head.html.haml
@@ -4,17 +4,14 @@
.nav-links.sub-nav.scrolling-tabs
%ul{ class: container_class }
= nav_link(path: 'projects#show') do
- = link_to project_path(@project), title: 'Project home', class: 'shortcuts-project' do
- %span
- Home
+ = link_to project_path(@project), title: _('Project home'), class: 'shortcuts-project' do
+ %span= _('Home')
= nav_link(path: 'projects#activity') do
- = link_to activity_project_path(@project), title: 'Activity', class: 'shortcuts-project-activity' do
- %span
- Activity
+ = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do
+ %span= _('Activity')
- if can?(current_user, :read_cycle_analytics, @project)
= nav_link(path: 'cycle_analytics#show') do
- = link_to project_cycle_analytics_path(@project), title: 'Cycle Analytics', class: 'shortcuts-project-cycle-analytics' do
- %span
- Cycle Analytics
+ = link_to project_cycle_analytics_path(@project), title: _('Cycle Analytics'), class: 'shortcuts-project-cycle-analytics' do
+ %span= _('Cycle Analytics')
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 0fd19780570..873b3045ea9 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -14,7 +14,7 @@
- if forked_from_project = @project.forked_from_project
%p
- Forked from
+ #{ s_('ForkedFromProjectPath|Forked from') }
= link_to project_path(forked_from_project) do
= forked_from_project.namespace.try(:name)
@@ -24,7 +24,7 @@
= render 'projects/buttons/fork'
%span.hidden-xs
- - if @project.feature_available?(:repository, current_user)
+ - if can?(current_user, :download_code, @project)
.project-clone-holder
= render "shared/clone_panel"
diff --git a/app/views/projects/_last_commit.html.haml b/app/views/projects/_last_commit.html.haml
deleted file mode 100644
index df3b1c75508..00000000000
--- a/app/views/projects/_last_commit.html.haml
+++ /dev/null
@@ -1,12 +0,0 @@
-- ref = local_assigns.fetch(:ref)
-- status = commit.status(ref)
-- if status
- = link_to pipelines_namespace_project_commit_path(commit.project.namespace, commit.project, commit), class: "ci-status ci-#{status}" do
- = ci_icon_for_status(status)
- = ci_text_for_status(status)
-
-= link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit_short_id"
-= link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit), class: "commit-row-message"
-&middot;
-#{time_ago_with_tooltip(commit.committed_date)} by
-= commit_author_link(commit, avatar: true, size: 24)
diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml
index 768bc1fb323..f1ef50d2de2 100644
--- a/app/views/projects/_last_push.html.haml
+++ b/app/views/projects/_last_push.html.haml
@@ -1,18 +1,18 @@
-- if event = last_push_event
- - if show_last_push_widget?(event)
- .row-content-block.top-block.hidden-xs.white
- %div{ class: container_class }
- .event-last-push
- .event-last-push-text
- %span You pushed to
- = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name) do
- %strong= event.ref_name
- - if @project && event.project != @project
- %span at
- %strong= link_to_project event.project
- = clipboard_button(text: event.ref_name, class: 'btn-clipboard btn-transparent', title: 'Copy branch to clipboard')
- #{time_ago_with_tooltip(event.created_at)}
+- event = last_push_event
+- if event && show_last_push_widget?(event)
+ .row-content-block.top-block.hidden-xs.white
+ .event-last-push
+ .event-last-push-text
+ %span You pushed to
+ %strong
+ = link_to event.ref_name, namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), class: 'ref-name'
- .pull-right
- = link_to new_mr_path_from_push_event(event), title: "New merge request", class: "btn btn-info btn-sm" do
- Create merge request
+ - if event.project != @project
+ %span at
+ %strong= link_to_project event.project
+
+ #{time_ago_with_tooltip(event.created_at)}
+
+ .pull-right
+ = link_to new_mr_path_from_push_event(event), title: "New merge request", class: "btn btn-info btn-sm" do
+ #{ _('Create merge request') }
diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml
index d0698285f84..07445434cf3 100644
--- a/app/views/projects/_md_preview.html.haml
+++ b/app/views/projects/_md_preview.html.haml
@@ -9,12 +9,6 @@
%li
%a.js-md-preview-button{ href: "#md-preview-holder", tabindex: -1 }
Preview
-
- - if defined?(@issue) && @issue.confidential?
- %li.confidential-issue-warning
- = icon('warning')
- %span This is a confidential issue. Your comment will not be visible to the public.
-
%li.pull-right
.toolbar-group
= markdown_toolbar_button({ icon: "bold fw", data: { "md-tag" => "**" }, title: "Add bold text" })
diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml
deleted file mode 100644
index c0d12cbc66e..00000000000
--- a/app/views/projects/_readme.html.haml
+++ /dev/null
@@ -1,21 +0,0 @@
-- if readme = @repository.readme
- %article.readme-holder
- .pull-right
- - if can?(current_user, :push_code, @project)
- = link_to icon('pencil'), namespace_project_edit_blob_path(@project.namespace, @project, tree_join(@repository.root_ref, readme.name)), class: 'light edit-project-readme'
- .file-content.wiki
- = markup(readme.name, readme.data, rendered: @repository.rendered_readme)
-- else
- .row-content-block.second-block.center
- %h3.page-title
- This project does not have a README yet
- - if can?(current_user, :push_code, @project)
- %p
- A
- %code README
- file contains information about other files in a repository and is commonly
- distributed with computer software, forming part of its documentation.
- %p
- We recommend you to
- = link_to "add a README", add_special_file_path(@project, file_name: 'README.md'), class: 'underlined-link'
- file to the repository and GitLab will render it here instead of this message.
diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml
index 0c8241053e7..3b3d08ddd3c 100644
--- a/app/views/projects/_zen.html.haml
+++ b/app/views/projects/_zen.html.haml
@@ -1,10 +1,11 @@
- @gfm_form = true
+- current_text ||= nil
- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false)
.zen-backdrop
- classes << ' js-gfm-input js-autosize markdown-area'
- if defined?(f) && f
= f.text_area attr, class: classes, placeholder: placeholder, data: { supports_slash_commands: supports_slash_commands }
- else
- = text_area_tag attr, nil, class: classes, placeholder: placeholder
+ = text_area_tag attr, current_text, class: classes, placeholder: placeholder
%a.zen-control.zen-control-leave.js-zen-leave{ href: "#" }
= icon('compress')
diff --git a/app/views/projects/activity.html.haml b/app/views/projects/activity.html.haml
index 27c8e3c7fca..ef8d8051cbf 100644
--- a/app/views/projects/activity.html.haml
+++ b/app/views/projects/activity.html.haml
@@ -1,3 +1,5 @@
+- @no_container = true
+
- page_title "Activity"
= render "projects/head"
diff --git a/app/views/projects/artifacts/_tree_directory.html.haml b/app/views/projects/artifacts/_tree_directory.html.haml
index 34d5c3b7285..e2966ec33c2 100644
--- a/app/views/projects/artifacts/_tree_directory.html.haml
+++ b/app/views/projects/artifacts/_tree_directory.html.haml
@@ -1,4 +1,4 @@
-- path_to_directory = browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: directory.path)
+- path_to_directory = browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: directory.path)
%tr.tree-item{ 'data-link' => path_to_directory }
%td.tree-item-file-name
diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml
index ce7e25d774b..ea0b43b85cf 100644
--- a/app/views/projects/artifacts/_tree_file.html.haml
+++ b/app/views/projects/artifacts/_tree_file.html.haml
@@ -1,4 +1,4 @@
-- path_to_file = file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: file.path)
+- path_to_file = file_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: file.path)
%tr.tree-item{ 'data-link' => path_to_file }
- blob = file.blob
diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml
index 9fbb30f7c7c..961c805dc7c 100644
--- a/app/views/projects/artifacts/browse.html.haml
+++ b/app/views/projects/artifacts/browse.html.haml
@@ -1,22 +1,22 @@
- page_title @path.presence, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs'
= render "projects/pipelines/head"
-= render "projects/builds/header", show_controls: false
+= render "projects/jobs/header", show_controls: false
.tree-holder
.nav-block
.tree-controls
- = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build),
+ = link_to download_namespace_project_job_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)
+ = link_to 'Artifacts', browse_namespace_project_job_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)
+ = link_to truncate(title, length: 40), browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path)
.tree-content-holder
%table.table.tree-table
diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml
index d8da83b9a80..b25c7c95196 100644
--- a/app/views/projects/artifacts/file.html.haml
+++ b/app/views/projects/artifacts/file.html.haml
@@ -1,21 +1,21 @@
- page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs'
= render "projects/pipelines/head"
-= render "projects/builds/header", show_controls: false
+= render "projects/jobs/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)
+ = link_to 'Artifacts', browse_namespace_project_job_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
+ = link_to file_namespace_project_job_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)
+ = link_to title, browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path)
%article.file-holder
diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml
index 35885b2c7b4..a6ee2b2f7b8 100644
--- a/app/views/projects/blame/show.html.haml
+++ b/app/views/projects/blame/show.html.haml
@@ -1,11 +1,11 @@
- @no_container = true
-- page_title "Blame", @blob.path, @ref
+- page_title "Annotate", @blob.path, @ref
= render "projects/commits/head"
%div{ class: container_class }
- %h3.page-title Blame view
-
#blob-content-holder.tree-holder
+ = render "projects/blob/breadcrumb", blob: @blob, blame: true
+
.file-holder
= render "projects/blob/header", blob: @blob, blame: true
@@ -22,7 +22,7 @@
%strong
= link_to_gfm truncate(commit.title, length: 35), namespace_project_commit_path(@project.namespace, @project, commit.id), class: "cdark"
.pull-right
- = link_to commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit), class: "monospace"
+ = link_to commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit), class: "commit-sha"
&nbsp;
.light
= commit_author_link(commit, avatar: false)
diff --git a/app/views/projects/blob/_auxiliary_viewer.html.haml b/app/views/projects/blob/_auxiliary_viewer.html.haml
new file mode 100644
index 00000000000..9749afdc580
--- /dev/null
+++ b/app/views/projects/blob/_auxiliary_viewer.html.haml
@@ -0,0 +1,5 @@
+- blob = local_assigns.fetch(:blob)
+- auxiliary_viewer = blob.auxiliary_viewer
+- if auxiliary_viewer && auxiliary_viewer.render_error.nil? && auxiliary_viewer.visible_to?(current_user)
+ .well-segment.blob-auxiliary-viewer
+ = render 'projects/blob/viewer', viewer: auxiliary_viewer
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index f04df441ccb..8bd336269ff 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -1,23 +1,11 @@
-.nav-block
- .tree-ref-holder
- = render 'shared/ref_switcher', destination: 'blob', path: @path
+= render "projects/blob/breadcrumb", blob: blob
- %ul.breadcrumb.repo-breadcrumb
- %li
- = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do
- = @project.path
- - path_breadcrumbs do |title, path|
- - title = truncate(title, length: 40)
- %li
- - if path == @path
- = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, path)) do
- %strong= title
- - else
- = link_to title, namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path))
+.info-well.hidden-xs
+ .well-segment
+ %ul.blob-commit-info
+ = render 'projects/commits/commit', commit: @last_commit, project: @project, ref: @ref
-%ul.blob-commit-info.hidden-xs
- - blob_commit = @repository.last_commit_for_path(@commit.id, blob.path)
- = render blob_commit, project: @project, ref: @ref
+ = render "projects/blob/auxiliary_viewer", blob: blob
#blob-content-holder.blob-content-holder
%article.file-holder
diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml
new file mode 100644
index 00000000000..0ad9f258e48
--- /dev/null
+++ b/app/views/projects/blob/_breadcrumb.html.haml
@@ -0,0 +1,36 @@
+- blame = local_assigns.fetch(:blame, false)
+.nav-block
+ .tree-controls
+ = render 'projects/find_file_link'
+
+ .btn-group.prepend-left-10{ role: "group" }<
+ -# only show normal/blame view links for text files
+ - if blob.readable_text?
+ - if blame
+ = link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id),
+ class: 'btn'
+ - else
+ = link_to 'Annotate', namespace_project_blame_path(@project.namespace, @project, @id),
+ class: 'btn js-blob-blame-link' unless blob.empty?
+
+ = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id),
+ class: 'btn'
+
+ = link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project,
+ tree_join(@commit.sha, @path)), class: 'btn js-data-file-blob-permalink-url'
+
+ .tree-ref-holder
+ = render 'shared/ref_switcher', destination: 'blob', path: @path
+
+ %ul.breadcrumb.repo-breadcrumb
+ %li
+ = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do
+ = @project.path
+ - path_breadcrumbs do |title, path|
+ - title = truncate(title, length: 40)
+ %li
+ - if path == @path
+ = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, path)) do
+ %strong= title
+ - else
+ = link_to title, namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path))
diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml
index cd098acda81..0be15cc179f 100644
--- a/app/views/projects/blob/_header.html.haml
+++ b/app/views/projects/blob/_header.html.haml
@@ -11,23 +11,7 @@
= view_on_environment_button(@commit.sha, @path, @environment) if @environment
.btn-group{ role: "group" }<
- -# only show normal/blame view links for text files
- - if blob.readable_text?
- - if blame
- = link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id),
- class: 'btn btn-sm'
- - else
- = link_to 'Blame', namespace_project_blame_path(@project.namespace, @project, @id),
- class: 'btn btn-sm js-blob-blame-link' unless blob.empty?
-
- = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id),
- class: 'btn btn-sm'
-
- = link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project,
- tree_join(@commit.sha, @path)), class: 'btn btn-sm js-data-file-blob-permalink-url'
-
- .btn-group{ role: "group" }<
- = edit_blob_link if blob.readable_text?
+ = edit_blob_link
- if current_user
= replace_blob_link
= delete_blob_link
diff --git a/app/views/projects/blob/_markup.html.haml b/app/views/projects/blob/_markup.html.haml
deleted file mode 100644
index 0090f7a11df..00000000000
--- a/app/views/projects/blob/_markup.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- blob.load_all_data!(@repository)
-
-.file-content.wiki
- = markup(blob.name, blob.data)
diff --git a/app/views/projects/blob/_new_dir.html.haml b/app/views/projects/blob/_new_dir.html.haml
index 7f470b890ba..40978583e8b 100644
--- a/app/views/projects/blob/_new_dir.html.haml
+++ b/app/views/projects/blob/_new_dir.html.haml
@@ -3,18 +3,18 @@
.modal-content
.modal-header
%a.close{ href: "#", "data-dismiss" => "modal" } ×
- %h3.page-title Create New Directory
+ %h3.page-title= _('Create New Directory')
.modal-body
= form_tag namespace_project_create_dir_path(@project.namespace, @project, @id), method: :post, remote: false, class: 'form-horizontal js-create-dir-form js-quick-submit js-requires-input' do
.form-group
- = label_tag :dir_name, 'Directory name', class: 'control-label'
+ = label_tag :dir_name, _('Directory name'), class: 'control-label'
.col-sm-10
= text_field_tag :dir_name, params[:dir_name], required: true, class: 'form-control'
- = render 'shared/new_commit_form', placeholder: "Add new directory"
+ = render 'shared/new_commit_form', placeholder: _("Add new directory")
.form-actions
- = submit_tag "Create directory", class: 'btn btn-create'
+ = submit_tag _("Create directory"), class: 'btn btn-create'
= link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal"
- unless can?(current_user, :push_code, @project)
diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml
index 5326bb3e0cf..4252f27d007 100644
--- a/app/views/projects/blob/_viewer.html.haml
+++ b/app/views/projects/blob/_viewer.html.haml
@@ -1,12 +1,11 @@
- hidden = local_assigns.fetch(:hidden, false)
- render_error = viewer.render_error
-- load_asynchronously = local_assigns.fetch(:load_asynchronously, viewer.server_side?) && render_error.nil?
+- load_async = local_assigns.fetch(:load_async, viewer.load_async?)
-- url = url_for(params.merge(viewer: viewer.type, format: :json)) if load_asynchronously
-.blob-viewer{ data: { type: viewer.type, url: url }, class: ('hidden' if hidden) }
- - if load_asynchronously
- .text-center.prepend-top-default.append-bottom-default
- = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content')
+- viewer_url = local_assigns.fetch(:viewer_url) { url_for(params.merge(viewer: viewer.type, format: :json)) } if load_async
+.blob-viewer{ data: { type: viewer.type, url: viewer_url }, class: ('hidden' if hidden) }
+ - if load_async
+ = render viewer.loading_partial_path, viewer: viewer
- elsif render_error
= render 'projects/blob/render_error', viewer: viewer
- else
diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml
index e87b73c9a34..da2cef17e8a 100644
--- a/app/views/projects/blob/preview.html.haml
+++ b/app/views/projects/blob/preview.html.haml
@@ -1,4 +1,4 @@
-.diff-file
+.diff-file.file-holder
.diff-content
- if markup?(@blob.name)
.file-content.wiki
diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml
index 67f57b5e4b9..41f75a491a5 100644
--- a/app/views/projects/blob/show.html.haml
+++ b/app/views/projects/blob/show.html.haml
@@ -1,13 +1,14 @@
- @no_container = true
+
- page_title @blob.path, @ref
= render "projects/commits/head"
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('blob')
-%div{ class: container_class }
- = render 'projects/last_push'
+= render 'projects/last_push'
+%div{ class: container_class }
#tree-holder.tree-holder
= render 'blob', blob: @blob
diff --git a/app/views/projects/blob/viewers/_changelog.html.haml b/app/views/projects/blob/viewers/_changelog.html.haml
new file mode 100644
index 00000000000..53921e63b5f
--- /dev/null
+++ b/app/views/projects/blob/viewers/_changelog.html.haml
@@ -0,0 +1,4 @@
+= icon('history fw')
+= succeed '.' do
+ To find the state of this project's repository at the time of any of these versions, check out
+ = link_to "the tags", namespace_project_tags_path(viewer.project.namespace, viewer.project)
diff --git a/app/views/projects/blob/viewers/_contributing.html.haml b/app/views/projects/blob/viewers/_contributing.html.haml
new file mode 100644
index 00000000000..c78f04c9c7c
--- /dev/null
+++ b/app/views/projects/blob/viewers/_contributing.html.haml
@@ -0,0 +1,9 @@
+= icon('book fw')
+After you've reviewed these contribution guidelines, you'll be all set to
+
+- options = contribution_options(viewer.project)
+- if options.any?
+ = succeed '.' do
+ = options.to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe
+- else
+ contribute to this project.
diff --git a/app/views/projects/blob/viewers/_dependency_manager.html.haml b/app/views/projects/blob/viewers/_dependency_manager.html.haml
new file mode 100644
index 00000000000..a0f0215a5ff
--- /dev/null
+++ b/app/views/projects/blob/viewers/_dependency_manager.html.haml
@@ -0,0 +1,11 @@
+= icon('cubes fw')
+= succeed '.' do
+ This project manages its dependencies using
+ %strong= viewer.manager_name
+
+ - if viewer.package_name
+ and defines a #{viewer.package_type} named
+ %strong<
+ = link_to viewer.package_name, viewer.package_url, target: '_blank', rel: 'noopener noreferrer'
+
+= link_to 'Learn more', viewer.manager_url, target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml
new file mode 100644
index 00000000000..28c5be6ebf3
--- /dev/null
+++ b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml
@@ -0,0 +1,9 @@
+- if viewer.valid?
+ = icon('check fw')
+ This GitLab CI configuration is valid.
+- else
+ = icon('warning fw')
+ This GitLab CI configuration is invalid:
+ = viewer.validation_message
+
+= link_to 'Learn more', help_page_path('ci/yaml/README')
diff --git a/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml b/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml
new file mode 100644
index 00000000000..10cbf6a2f7a
--- /dev/null
+++ b/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml
@@ -0,0 +1,4 @@
+= icon('spinner spin fw')
+Validating GitLab CI configuration…
+
+= link_to 'Learn more', help_page_path('ci/yaml/README')
diff --git a/app/views/projects/blob/viewers/_license.html.haml b/app/views/projects/blob/viewers/_license.html.haml
new file mode 100644
index 00000000000..fb9d0b99d09
--- /dev/null
+++ b/app/views/projects/blob/viewers/_license.html.haml
@@ -0,0 +1,8 @@
+- license = viewer.license
+
+= icon('balance-scale fw')
+This project is licensed under the
+= succeed '.' do
+ %strong= license.name
+
+= link_to 'Learn more', license.url, target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/projects/blob/viewers/_loading.html.haml b/app/views/projects/blob/viewers/_loading.html.haml
new file mode 100644
index 00000000000..120c0540335
--- /dev/null
+++ b/app/views/projects/blob/viewers/_loading.html.haml
@@ -0,0 +1,2 @@
+.text-center.prepend-top-default.append-bottom-default
+ = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content…')
diff --git a/app/views/projects/blob/viewers/_loading_auxiliary.html.haml b/app/views/projects/blob/viewers/_loading_auxiliary.html.haml
new file mode 100644
index 00000000000..c7dc9e3250a
--- /dev/null
+++ b/app/views/projects/blob/viewers/_loading_auxiliary.html.haml
@@ -0,0 +1,2 @@
+= icon('spinner spin fw')
+Analyzing file…
diff --git a/app/views/projects/blob/viewers/_readme.html.haml b/app/views/projects/blob/viewers/_readme.html.haml
new file mode 100644
index 00000000000..334b33faf48
--- /dev/null
+++ b/app/views/projects/blob/viewers/_readme.html.haml
@@ -0,0 +1,4 @@
+= icon('info-circle fw')
+= succeed '.' do
+ To learn more about this project, read
+ = link_to "the wiki", namespace_project_wikis_path(viewer.project.namespace, viewer.project)
diff --git a/app/views/projects/blob/viewers/_route_map.html.haml b/app/views/projects/blob/viewers/_route_map.html.haml
new file mode 100644
index 00000000000..d0fcd55f6c1
--- /dev/null
+++ b/app/views/projects/blob/viewers/_route_map.html.haml
@@ -0,0 +1,9 @@
+- if viewer.valid?
+ = icon('check fw')
+ This Route Map is valid.
+- else
+ = icon('warning fw')
+ This Route Map is invalid:
+ = viewer.validation_message
+
+= link_to 'Learn more', help_page_path('ci/environments', anchor: 'route-map')
diff --git a/app/views/projects/blob/viewers/_route_map_loading.html.haml b/app/views/projects/blob/viewers/_route_map_loading.html.haml
new file mode 100644
index 00000000000..2318cf82f58
--- /dev/null
+++ b/app/views/projects/blob/viewers/_route_map_loading.html.haml
@@ -0,0 +1,4 @@
+= icon('spinner spin fw')
+Validating Route Map…
+
+= link_to 'Learn more', help_page_path('ci/environments', anchor: 'route-map')
diff --git a/app/views/projects/boards/_show.html.haml b/app/views/projects/boards/_show.html.haml
index efec69662f3..6684ecfce81 100644
--- a/app/views/projects/boards/_show.html.haml
+++ b/app/views/projects/boards/_show.html.haml
@@ -26,6 +26,7 @@
":disabled" => "disabled",
":issue-link-base" => "issueLinkBase",
":root-path" => "rootPath",
+ ":board-id" => "boardId",
":key" => "_uid" }
= render "projects/boards/components/sidebar"
%board-add-issues-modal{ "blank-state-image" => render('shared/empty_states/icons/issues.svg'),
diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml
index bc5c727bf0d..55c4d51be14 100644
--- a/app/views/projects/boards/components/_board.html.haml
+++ b/app/views/projects/boards/components/_board.html.haml
@@ -1,8 +1,11 @@
-.board{ ":class" => '{ "is-draggable": !list.preset }',
+.board{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded }',
":data-id" => "list.id" }
.board-inner
- %header.board-header{ ":class" => '{ "has-border": list.label }', ":style" => "{ borderTopColor: (list.label ? list.label.color : null) }" }
+ %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" }
%h3.board-title.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset) }' }
+ %i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable",
+ ":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded && list.position === -1, \"fa-caret-left\": !list.isExpanded && list.position !== -1 }",
+ "aria-hidden": "true" }
%span.has-tooltip{ ":title" => '(list.label ? list.label.description : "")',
data: { container: "body", placement: "bottom" } }
{{ list.title }}
@@ -10,13 +13,13 @@
%span.board-issue-count.pull-left{ ":class" => '{ "has-btn": list.type !== "closed" && !disabled }' }
{{ list.issuesSize }}
- if can?(current_user, :admin_issue, @project)
- %button.btn.btn-small.btn-default.pull-right.has-tooltip{ type: "button",
+ %button.btn.btn-small.btn-default.pull-right.has-tooltip.js-no-trigger-collapse{ type: "button",
"@click" => "showNewIssueForm",
"v-if" => 'list.type !== "closed"',
"aria-label" => "New issue",
"title" => "New issue",
data: { placement: "top", container: "body" } }
- = icon("plus")
+ = icon("plus", class: "js-no-trigger-collapse")
- if can?(current_user, :admin_list, @project)
%board-delete{ "inline-template" => true,
":list" => "list",
diff --git a/app/views/projects/boards/components/sidebar/_assignee.html.haml b/app/views/projects/boards/components/sidebar/_assignee.html.haml
index 48f8c656080..e8db868f49b 100644
--- a/app/views/projects/boards/components/sidebar/_assignee.html.haml
+++ b/app/views/projects/boards/components/sidebar/_assignee.html.haml
@@ -14,7 +14,10 @@
name: "issue[assignee_ids][]",
":value" => "assignee.id",
"v-if" => "issue.assignees",
- "v-for" => "assignee in issue.assignees" }
+ "v-for" => "assignee in issue.assignees",
+ ":data-avatar_url" => "assignee.avatar",
+ ":data-name" => "assignee.name",
+ ":data-username" => "assignee.username" }
.dropdown
%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",
diff --git a/app/views/projects/boards/components/sidebar/_milestone.html.haml b/app/views/projects/boards/components/sidebar/_milestone.html.haml
index 190e7290303..4e46351bf8a 100644
--- a/app/views/projects/boards/components/sidebar/_milestone.html.haml
+++ b/app/views/projects/boards/components/sidebar/_milestone.html.haml
@@ -16,7 +16,8 @@
name: "issue[milestone_id]",
"v-if" => "issue.milestone" }
.dropdown
- %button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: "issue", use_id: "true" },
+ %button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: "issue", use_id: "true", default_no: "true" },
+ ":data-selected" => "milestoneTitle",
":data-issuable-id" => "issue.id",
":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" }
Milestone
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 0f9ef3eded3..869633e016d 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -6,7 +6,8 @@
- merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
%li{ class: "js-branch-#{branch.name}" }
%div
- = link_to namespace_project_tree_path(@project.namespace, @project, branch.name), class: 'item-title str-truncated' do
+ = link_to namespace_project_tree_path(@project.namespace, @project, branch.name), class: 'item-title str-truncated ref-name' do
+ = icon('code-fork')
= branch.name
&nbsp;
- if branch.name == @repository.root_ref
@@ -30,16 +31,37 @@
= render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name]
- if can?(current_user, :push_code, @project)
- = link_to namespace_project_branch_path(@project.namespace, @project, branch.name),
- class: "btn btn-remove remove-row js-ajax-loading-spinner #{can_remove_branch?(@project, branch.name) ? '' : 'disabled'}",
- method: :delete,
- data: { confirm: "Deleting the '#{branch.name}' branch cannot be undone. Are you sure?" },
- remote: true,
- "aria-label" => "Delete branch" do
- = icon("trash-o")
+ - if branch.name == @project.repository.root_ref
+ %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled",
+ disabled: true,
+ title: "The default branch cannot be deleted" }
+ = icon("trash-o")
+ - elsif protected_branch?(@project, branch)
+ - if can?(current_user, :delete_protected_branch, @project)
+ %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip",
+ title: "Delete protected branch",
+ data: { toggle: "modal",
+ target: "#modal-delete-branch",
+ delete_path: namespace_project_branch_path(@project.namespace, @project, branch.name),
+ branch_name: branch.name } }
+ = icon("trash-o")
+ - else
+ %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled",
+ disabled: true,
+ title: "Only a project master or owner can delete a protected branch" }
+ = icon("trash-o")
+ - else
+ = link_to namespace_project_branch_path(@project.namespace, @project, branch.name),
+ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip",
+ title: "Delete branch",
+ method: :delete,
+ data: { confirm: "Deleting the '#{branch.name}' branch cannot be undone. Are you sure?" },
+ remote: true,
+ "aria-label" => "Delete branch" do
+ = icon("trash-o")
- if branch.name != @repository.root_ref
- .divergence-graph{ title: "#{number_commits_ahead} commits ahead, #{number_commits_behind} commits behind #{@repository.root_ref}" }
+ .divergence-graph{ title: "#{number_commits_behind} commits behind #{@repository.root_ref}, #{number_commits_ahead} commits ahead" }
.graph-side
.bar.bar-behind{ style: "width: #{number_commits_behind * bar_graph_width_factor}%" }
%span.count.count-behind= number_commits_behind
diff --git a/app/views/projects/branches/_commit.html.haml b/app/views/projects/branches/_commit.html.haml
index de607772df6..ad8f9da0621 100644
--- a/app/views/projects/branches/_commit.html.haml
+++ b/app/views/projects/branches/_commit.html.haml
@@ -1,7 +1,7 @@
.branch-commit
.icon-container.commit-icon
= custom_icon("icon_commit")
- = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-id monospace"
+ = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-sha"
&middot;
%span.str-truncated
= link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message"
diff --git a/app/views/projects/branches/_delete_protected_modal.html.haml b/app/views/projects/branches/_delete_protected_modal.html.haml
new file mode 100644
index 00000000000..c5888afa54d
--- /dev/null
+++ b/app/views/projects/branches/_delete_protected_modal.html.haml
@@ -0,0 +1,34 @@
+#modal-delete-branch.modal{ tabindex: -1 }
+ .modal-dialog
+ .modal-content
+ .modal-header
+ %button.close{ data: { dismiss: 'modal' } } ×
+ %h3.page-title
+ Delete protected branch
+ = surround "'", "'?" do
+ %span.js-branch-name>[branch name]
+
+ .modal-body
+ %p
+ You’re about to permanently delete the protected branch
+ = succeed '.' do
+ %strong.js-branch-name [branch name]
+ %p
+ Once you confirm and press
+ = succeed ',' do
+ %strong Delete protected branch
+ it cannot be undone or recovered.
+ %p
+ %strong To confirm, type
+ %kbd.js-branch-name [branch name]
+
+ .form-group
+ = text_field_tag 'delete_branch_input', '', class: 'form-control js-delete-branch-input'
+
+ .modal-footer
+ %button.btn{ data: { dismiss: 'modal' } } Cancel
+ = link_to 'Delete protected branch', '',
+ class: "btn btn-danger js-delete-branch",
+ title: 'Delete branch',
+ method: :delete,
+ "aria-label" => "Delete"
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index 91b86280e4c..4bade77a077 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -37,3 +37,5 @@
= paginate @branches, theme: 'gitlab'
- else
.nothing-here-block No branches to show
+
+= render 'projects/branches/delete_protected_modal'
diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml
index 55575c5e412..5a0eba3551f 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -20,7 +20,7 @@
.col-sm-10.create-from
.dropdown
= hidden_field_tag :ref, default_ref
- = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide form-control js-branch-select', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do
+ = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide form-control js-branch-select git-revision-dropdown-toggle', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do
.text-left.dropdown-toggle-text= default_ref
= icon('chevron-down')
= render 'shared/ref_dropdown', dropdown_class: 'wide'
diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml
index d90d4a27cd6..3cf91bf07f7 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -2,29 +2,29 @@
- if !project.empty_repo? && can?(current_user, :download_code, project)
.project-action-button.dropdown.inline>
- %button.btn{ 'data-toggle' => 'dropdown' }
+ %button.btn.has-tooltip{ title: 'Download', 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download') }
= icon('download')
= icon("caret-down")
- %span.sr-only
- Select Archive Format
+ %span.sr-only= _('Select Archive Format')
%ul.dropdown-menu.dropdown-menu-align-right{ role: 'menu' }
- %li.dropdown-header Source code
+ %li.dropdown-header
+ #{ _('Source code') }
%li
= link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'zip'), rel: 'nofollow', download: '' do
%i.fa.fa-download
- %span Download zip
+ %span= _('Download zip')
%li
= link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar.gz'), rel: 'nofollow', download: '' do
%i.fa.fa-download
- %span Download tar.gz
+ %span= _('Download tar.gz')
%li
= link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar.bz2'), rel: 'nofollow', download: '' do
%i.fa.fa-download
- %span Download tar.bz2
+ %span= _('Download tar.bz2')
%li
= link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar'), rel: 'nofollow', download: '' do
%i.fa.fa-download
- %span Download tar
+ %span= _('Download tar')
- if pipeline
- artifacts = pipeline.builds.latest.with_artifacts
@@ -39,4 +39,5 @@
%li
= link_to latest_succeeded_namespace_project_artifacts_path(project.namespace, project, "#{ref}/download", job: job.name), rel: 'nofollow', download: '' do
%i.fa.fa-download
- %span Download '#{job.name}'
+ %span
+ #{ s_('DownloadArtifacts|Download') } '#{job.name}'
diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml
index 67de8699b2e..312c349da3a 100644
--- a/app/views/projects/buttons/_dropdown.html.haml
+++ b/app/views/projects/buttons/_dropdown.html.haml
@@ -1,6 +1,6 @@
- if current_user
.project-action-button.dropdown.inline
- %a.btn.dropdown-toggle{ href: '#', "data-toggle" => "dropdown" }
+ %a.btn.dropdown-toggle.has-tooltip{ href: '#', title: 'Create new...', 'data-toggle' => 'dropdown', 'data-container' => 'body', 'aria-label' => 'Create new...' }
= icon('plus')
= icon("caret-down")
%ul.dropdown-menu.dropdown-menu-align-right.project-home-dropdown
@@ -12,19 +12,19 @@
%li
= link_to new_namespace_project_issue_path(@project.namespace, @project) do
= icon('exclamation-circle fw')
- New issue
+ #{ _('New issue') }
- if merge_project
%li
= link_to new_namespace_project_merge_request_path(merge_project.namespace, merge_project) do
= icon('tasks fw')
- New merge request
+ #{ _('New merge request') }
- if can_create_snippet
%li
= link_to new_namespace_project_snippet_path(@project.namespace, @project) do
= icon('file-text-o fw')
- New snippet
+ #{ _('New snippet') }
- if can_create_issue || merge_project || can_create_snippet
%li.divider
@@ -33,20 +33,20 @@
%li
= link_to namespace_project_new_blob_path(@project.namespace, @project, @project.default_branch || 'master') do
= icon('file fw')
- New file
+ #{ _('New file') }
%li
= link_to new_namespace_project_branch_path(@project.namespace, @project) do
= icon('code-fork fw')
- New branch
+ #{ _('New branch') }
%li
= link_to new_namespace_project_tag_path(@project.namespace, @project) do
= icon('tags fw')
- New tag
+ #{ _('New tag') }
- elsif current_user && current_user.already_forked?(@project)
%li
= link_to namespace_project_new_blob_path(@project.namespace, @project, @project.default_branch || 'master') do
= icon('file fw')
- New file
+ #{ _('New file') }
- elsif can?(current_user, :fork_project, @project)
%li
- continue_params = { to: namespace_project_new_blob_path(@project.namespace, @project, @project.default_branch || 'master'),
@@ -56,4 +56,4 @@
continue: continue_params)
= link_to fork_path, method: :post do
= icon('file fw')
- New file
+ #{ _('New file') }
diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml
index 851fe44a86d..7a08bb37494 100644
--- a/app/views/projects/buttons/_fork.html.haml
+++ b/app/views/projects/buttons/_fork.html.haml
@@ -1,14 +1,14 @@
- unless @project.empty_repo?
- if current_user && can?(current_user, :fork_project, @project)
- if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2
- = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: 'Go to your fork', class: 'btn has-tooltip' do
+ = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn has-tooltip' do
= custom_icon('icon_fork')
- %span Fork
+ %span= s_('GoToYourFork|Fork')
- else
- = link_to new_namespace_project_fork_path(@project.namespace, @project), title: 'Fork project', class: 'btn' do
+ = link_to new_namespace_project_fork_path(@project.namespace, @project), class: 'btn' do
= custom_icon('icon_fork')
- %span Fork
+ %span= s_('CreateNewFork|Fork')
.count-with-arrow
%span.arrow
- = link_to namespace_project_forks_path(@project.namespace, @project), title: 'Forks', class: 'count' do
+ = link_to namespace_project_forks_path(@project.namespace, @project), title: n_('Forks', @project.forks_count), class: 'count' do
= @project.forks_count
diff --git a/app/views/projects/buttons/_koding.html.haml b/app/views/projects/buttons/_koding.html.haml
index a5a9e4d0621..de2d61d4aa3 100644
--- a/app/views/projects/buttons/_koding.html.haml
+++ b/app/views/projects/buttons/_koding.html.haml
@@ -1,3 +1,3 @@
- if koding_enabled? && current_user && @repository.koding_yml && can_push_branch?(@project, @project.default_branch)
= link_to koding_project_url(@project), class: 'btn project-action-button inline', target: '_blank', rel: 'noopener noreferrer' do
- Run in IDE (Koding)
+ _('Run in IDE (Koding)')
diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml
index d57eb2cbfbc..58413e2fc52 100644
--- a/app/views/projects/buttons/_star.html.haml
+++ b/app/views/projects/buttons/_star.html.haml
@@ -2,19 +2,19 @@
= link_to toggle_star_namespace_project_path(@project.namespace, @project), { class: 'btn star-btn toggle-star', method: :post, remote: true } do
- if current_user.starred?(@project)
= icon('star')
- %span.starred Unstar
+ %span.starred= _('Unstar')
- else
= icon('star-o')
- %span Star
+ %span= s_('StarProject|Star')
.count-with-arrow
%span.arrow
%span.count.star-count
= @project.star_count
- else
- = link_to new_user_session_path, class: 'btn has-tooltip star-btn', title: 'You must sign in to star a project' do
+ = link_to new_user_session_path, class: 'btn has-tooltip star-btn', title: _('You must sign in to star a project') do
= icon('star')
- Star
+ #{ s_('StarProject|Star') }
.count-with-arrow
%span.arrow
%span.count
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index 84ac03237e1..d9f28d66b66 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -14,7 +14,7 @@
%td.branch-commit
- if can?(current_user, :read_build, job)
- = link_to namespace_project_build_url(job.project.namespace, job.project, job) do
+ = link_to namespace_project_job_url(job.project.namespace, job.project, job) do
%span.build-link ##{job.id}
- else
%span.build-link ##{job.id}
@@ -23,14 +23,14 @@
- if job.ref
.icon-container
= job.tag? ? icon('tag') : icon('code-fork')
- = link_to job.ref, namespace_project_commits_path(job.project.namespace, job.project, job.ref), class: "monospace branch-name"
+ = link_to job.ref, project_ref_path(job.project, job.ref), class: "ref-name"
- else
.light none
.icon-container.commit-icon
= custom_icon("icon_commit")
- if commit_sha
- = link_to job.short_sha, namespace_project_commit_path(job.project.namespace, job.project, job.sha), class: "commit-id monospace"
+ = link_to job.short_sha, namespace_project_commit_path(job.project.namespace, job.project, job.sha), class: "commit-sha"
- if job.stuck?
= icon('warning', class: 'text-warning has-tooltip', title: 'Job is stuck. Check runners.')
@@ -58,7 +58,7 @@
- if pipeline.user
= user_avatar(user: pipeline.user, size: 20)
- else
- %span.monospace API
+ %span.api API
- if admin
%td
@@ -95,16 +95,16 @@
%td
.pull-right
- if can?(current_user, :read_build, job) && job.artifacts?
- = link_to download_namespace_project_build_artifacts_path(job.project.namespace, job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do
+ = link_to download_namespace_project_job_artifacts_path(job.project.namespace, job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do
= icon('download')
- if can?(current_user, :update_build, job)
- if job.active?
- = link_to cancel_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do
+ = link_to cancel_namespace_project_job_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do
= icon('remove', class: 'cred')
- elsif allow_retry
- if job.playable? && !admin && can?(current_user, :update_build, job)
- = link_to play_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do
+ = link_to play_namespace_project_job_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do
= custom_icon('icon_play')
- elsif job.retryable?
- = link_to retry_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do
+ = link_to retry_namespace_project_job_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do
= icon('repeat')
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 64adb70cb81..aab50310234 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -1,6 +1,8 @@
.page-content-header
.header-main-content
- %strong Commit #{@commit.short_id}
+ %strong
+ Commit
+ %span.commit-sha= @commit.short_id
= clipboard_button(text: @commit.id, title: "Copy commit SHA to clipboard")
%span.hidden-xs authored
#{time_ago_with_tooltip(@commit.authored_date)}
@@ -57,7 +59,7 @@
= custom_icon("icon_commit")
%span.cgray= pluralize(@commit.parents.count, "parent")
- @commit.parents.each do |parent|
- = link_to parent.short_id, namespace_project_commit_path(@project.namespace, @project, parent), class: "monospace"
+ = link_to parent.short_id, namespace_project_commit_path(@project.namespace, @project, parent), class: "commit-sha"
%span.commit-info.branches
%i.fa.fa-spinner.fa-spin
@@ -68,9 +70,10 @@
= link_to namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id) do
= ci_icon_for_status(last_pipeline.status)
Pipeline
- = link_to "##{last_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id), class: "monospace"
+ = link_to "##{last_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id)
= ci_label_for_status(last_pipeline.status)
- - if last_pipeline.stages.any?
+ - if last_pipeline.stages_count.nonzero?
+ with #{"stage".pluralize(last_pipeline.stages_count)}
.mr-widget-pipeline-graph
= render 'shared/mini_pipeline_graph', pipeline: last_pipeline, klass: 'js-commit-pipeline-graph'
in
diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml
deleted file mode 100644
index 3ee85723ebe..00000000000
--- a/app/views/projects/commit/_pipeline.html.haml
+++ /dev/null
@@ -1,52 +0,0 @@
-.pipeline-graph-container
- .row-content-block.build-content.middle-block.pipeline-actions
- .pull-right
- - if can?(current_user, :update_pipeline, pipeline.project)
- - if pipeline.builds.latest.failed.any?(&:retryable?)
- = link_to "Retry", retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'js-retry-button btn btn-grouped btn-primary', method: :post
-
- - if pipeline.builds.running_or_pending.any?
- = link_to "Cancel running", cancel_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), data: { confirm: 'Are you sure?' }, class: 'btn btn-grouped btn-danger', method: :post
-
- .oneline.clearfix
- - if defined?(pipeline_details) && pipeline_details
- Pipeline
- = link_to "##{pipeline.id}", namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: "monospace"
- with
- = pluralize pipeline.statuses.count(:id), "job"
- - if pipeline.ref
- for
- = link_to pipeline.ref, namespace_project_commits_path(pipeline.project.namespace, pipeline.project, pipeline.ref), class: "monospace"
- - if defined?(link_to_commit) && link_to_commit
- for commit
- = link_to pipeline.short_sha, namespace_project_commit_path(pipeline.project.namespace, pipeline.project, pipeline.sha), class: "monospace"
- - if pipeline.duration
- in
- = time_interval_in_words pipeline.duration
-
- .row-content-block.build-content.middle-block.js-pipeline-graph.hidden
- = render "projects/pipelines/graph", pipeline: pipeline
-
-- if pipeline.yaml_errors.present?
- .bs-callout.bs-callout-danger
- %h4 Found errors in your .gitlab-ci.yml:
- %ul
- - pipeline.yaml_errors.split(",").each do |error|
- %li= error
- You can also test your .gitlab-ci.yml in the #{link_to "Lint", ci_lint_path}
-
-- if pipeline.project.builds_enabled? && !pipeline.ci_yaml_file
- .bs-callout.bs-callout-warning
- \.gitlab-ci.yml not found in this commit
-
-.table-holder.pipeline-holder
- %table.table.ci-table.pipeline
- %thead
- %tr
- %th Status
- %th Job ID
- %th Name
- %th
- %th Coverage
- %th
- = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage
diff --git a/app/views/projects/commit/branches.html.haml b/app/views/projects/commit/branches.html.haml
index 2b0c9a4b4de..911c9ddce06 100644
--- a/app/views/projects/commit/branches.html.haml
+++ b/app/views/projects/commit/branches.html.haml
@@ -1,15 +1,15 @@
-- if @branches.any?
- %span
- - branch = commit_default_branch(@project, @branches)
- = link_to(namespace_project_tree_path(@project.namespace, @project, branch)) do
- %span.label.label-gray
- = branch
- - if @branches.any? || @tags.any?
- = link_to("#", class: "js-details-expand") do
- %span.label.label-gray
- \...
+- if @branches.any? || @tags.any?
+ - branch = commit_default_branch(@project, @branches)
+ = link_to(project_ref_path(@project, branch), class: "label label-gray ref-name") do
+ = icon('code-fork')
+ = branch
+
+ -# `commit_default_branch` deletes the default branch from `@branches`,
+ -# so only render this if we have more branches left
+ - if @branches.any? || @tags.any?
+ %span
+ = link_to "…", "#", class: "js-details-expand label label-gray"
+
%span.js-details-content.hide
- - if @branches.any?
- = commit_branches_links(@project, @branches)
- - if @tags.any?
- = commit_tags_links(@project, @tags)
+ = commit_branches_links(@project, @branches) if @branches.any?
+ = commit_tags_links(@project, @tags) if @tags.any?
diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml
index 6051ea2f1ce..3a1be3fa4b6 100644
--- a/app/views/projects/commit/show.html.haml
+++ b/app/views/projects/commit/show.html.haml
@@ -13,7 +13,7 @@
.block-connector
= render "projects/diffs/diffs", diffs: @diffs, environment: @environment
- = render "shared/notes/notes_with_form"
+ = render "shared/notes/notes_with_form", :autocomplete => true
- 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/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index 8f32d2b72e5..7a03c3561af 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -31,12 +31,12 @@
= preserve(markdown(commit.description, pipeline: :single_line, author: commit.author))
.commiter
= commit_author_link(commit, avatar: false, size: 24)
- committed
+ #{ _('committed') }
#{time_ago_with_tooltip(commit.committed_date)}
.commit-actions.flex-row.hidden-xs
- if commit.status(ref)
= render_commit_status(commit, ref: ref)
- = clipboard_button(text: commit.id, title: "Copy commit SHA to clipboard")
- = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent"
+ = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-sha btn btn-transparent"
+ = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard"))
= link_to_browse_code(project, commit)
diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml
index 88c7d7bc44b..d3380c917e4 100644
--- a/app/views/projects/commits/_commits.html.haml
+++ b/app/views/projects/commits/_commits.html.haml
@@ -2,8 +2,11 @@
- commits, hidden = limited_commits(@commits)
- commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, commits|
- %li.commit-header #{day.strftime('%d %b, %Y')} #{pluralize(commits.count, 'commit')}
- %li.commits-row
+ %li.commit-header.js-commit-header{ data: { day: day } }
+ %span.day= day.strftime('%d %b, %Y')
+ %span.commits-count= pluralize(commits.count, 'commit')
+
+ %li.commits-row{ data: { day: day } }
%ul.content-list.commit-list
= render commits, project: project, ref: ref
diff --git a/app/views/projects/commits/_head.html.haml b/app/views/projects/commits/_head.html.haml
index dd6797f10c0..ebeaab863bc 100644
--- a/app/views/projects/commits/_head.html.haml
+++ b/app/views/projects/commits/_head.html.haml
@@ -5,32 +5,32 @@
%ul{ class: (container_class) }
= nav_link(controller: %w(tree blob blame edit_tree new_tree find_file)) do
= link_to project_files_path(@project) do
- Files
+ #{ _('Files') }
= nav_link(controller: [:commit, :commits]) do
= link_to namespace_project_commits_path(@project.namespace, @project, current_ref) do
- Commits
+ #{ _('Commits') }
= nav_link(html_options: {class: branches_tab_class}) do
= link_to namespace_project_branches_path(@project.namespace, @project) do
- Branches
+ #{ _('Branches') }
= nav_link(controller: [:tags, :releases]) do
= link_to namespace_project_tags_path(@project.namespace, @project) do
- Tags
+ #{ _('Tags') }
= nav_link(path: 'graphs#show') do
= link_to namespace_project_graph_path(@project.namespace, @project, current_ref) do
- Contributors
+ #{ _('Contributors') }
= nav_link(controller: %w(network)) do
= link_to namespace_project_network_path(@project.namespace, @project, current_ref) do
- Graph
+ #{ s_('ProjectNetworkGraph|Graph') }
= nav_link(controller: :compare) do
= link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: current_ref) do
- Compare
+ #{ _('Compare') }
= nav_link(path: 'graphs#charts') do
= link_to charts_namespace_project_graph_path(@project.namespace, @project, current_ref) do
- Charts
+ #{ _('Charts') }
diff --git a/app/views/projects/commits/_inline_commit.html.haml b/app/views/projects/commits/_inline_commit.html.haml
index c03bc3f9df9..5fb89935467 100644
--- a/app/views/projects/commits/_inline_commit.html.haml
+++ b/app/views/projects/commits/_inline_commit.html.haml
@@ -1,6 +1,6 @@
%li.commit.inline-commit
.commit-row-title
- = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit_short_id"
+ = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-sha"
&nbsp;
%span.str-truncated
= link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message"
diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml
index 1f4c9fac54c..adb724c1b8d 100644
--- a/app/views/projects/compare/_form.html.haml
+++ b/app/views/projects/compare/_form.html.haml
@@ -7,7 +7,7 @@
.input-group.inline-input-group
%span.input-group-addon from
= 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
+ = button_tag type: 'button', title: params[:from], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip git-revision-dropdown-toggle", 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 'shared/ref_dropdown'
.compare-ellipsis.inline ...
@@ -15,7 +15,7 @@
.input-group.inline-input-group
%span.input-group-addon to
= 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
+ = button_tag type: 'button', title: params[:to], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip git-revision-dropdown-toggle", 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 'shared/ref_dropdown'
&nbsp;
diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml
index 45be6581cfc..2cf14859f30 100644
--- a/app/views/projects/compare/index.html.haml
+++ b/app/views/projects/compare/index.html.haml
@@ -6,10 +6,10 @@
.sub-header-block
Compare Git revisions.
%br
- Fill input field with commit id like
- %code.label-branch 4eedf23
+ Fill input field with commit SHA like
+ %code.ref-name 4eedf23
or branch/tag name like
- %code.label-branch master
+ %code.ref-name master
and press compare button for the commits list and a code diff.
%br
Changes are shown <b>from</b> the version in the first field <b>to</b> the version in the second field.
diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml
index 0dfc9fe20ed..a1bca2cf83a 100644
--- a/app/views/projects/compare/show.html.haml
+++ b/app/views/projects/compare/show.html.haml
@@ -16,9 +16,9 @@
There isn't anything to compare.
%p.slead
- if params[:to] == params[:from]
- %span.label-branch= params[:from]
+ %span.ref-name= params[:from]
and
- %span.label-branch= params[:to]
+ %span.ref-name= params[:to]
are the same.
- else
You'll need to use different branch names to get a valid comparison.
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index 74255167352..7000b289f75 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -2,7 +2,6 @@
- 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"
diff --git a/app/views/projects/deploy_keys/_deploy_key.html.haml b/app/views/projects/deploy_keys/_deploy_key.html.haml
deleted file mode 100644
index ec8fc4c9ee8..00000000000
--- a/app/views/projects/deploy_keys/_deploy_key.html.haml
+++ /dev/null
@@ -1,30 +0,0 @@
-%li
- .pull-left.append-right-10.hidden-xs
- = icon "key", class: "key-icon"
- .deploy-key-content.key-list-item-info
- %strong.title
- = deploy_key.title
- .description
- = deploy_key.fingerprint
- - if deploy_key.can_push?
- .write-access-allowed
- Write access allowed
- .deploy-key-content.prepend-left-default.deploy-key-projects
- - deploy_key.projects.each do |project|
- - if can?(current_user, :read_project, project)
- = link_to namespace_project_path(project.namespace, project), class: "label deploy-project-label" do
- = project.name_with_namespace
- .deploy-key-content
- %span.key-created-at
- created #{time_ago_with_tooltip(deploy_key.created_at)}
- .visible-xs-block.visible-sm-block
- - if @deploy_keys.key_available?(deploy_key)
- = link_to enable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), class: "btn btn-sm prepend-left-10", method: :put do
- Enable
- - else
- - if deploy_key.destroyed_when_orphaned? && deploy_key.almost_orphaned?
- = link_to disable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), data: { confirm: "You are going to remove deploy key. Are you sure?" }, method: :put, class: "btn btn-warning btn-sm prepend-left-10" do
- Remove
- - else
- = link_to disable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), class: "btn btn-warning btn-sm prepend-left-10", method: :put do
- Disable
diff --git a/app/views/projects/deploy_keys/_form.html.haml b/app/views/projects/deploy_keys/_form.html.haml
index 1421da72418..edaa3a1119e 100644
--- a/app/views/projects/deploy_keys/_form.html.haml
+++ b/app/views/projects/deploy_keys/_form.html.haml
@@ -2,7 +2,7 @@
= form_errors(@deploy_keys.new_key)
.form-group
= f.label :title, class: "label-light"
- = f.text_field :title, class: 'form-control', autofocus: true, required: true
+ = f.text_field :title, class: 'form-control', required: true
.form-group
= f.label :key, class: "label-light"
= f.text_area :key, class: "form-control", rows: 5, required: true
diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml
index 74756b58439..6e038ffd9c0 100644
--- a/app/views/projects/deploy_keys/_index.html.haml
+++ b/app/views/projects/deploy_keys/_index.html.haml
@@ -1,13 +1,15 @@
-.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
- %h4.prepend-top-0
+- expanded = Rails.env.test?
+%section.settings
+ .settings-header
+ %h4
Deploy Keys
+ %button.btn.js-settings-toggle
+ = expanded ? 'Close' : 'Expand'
%p
Deploy keys allow read-only or read-write (if enabled) access to your repository. Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one.
- .col-lg-9
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
%h5.prepend-top-0
Create a new deploy key for this project
= render @deploy_keys.form_partial_path
- .col-lg-9.col-lg-offset-3
%hr
- #js-deploy-keys{ data: { endpoint: namespace_project_deploy_keys_path } }
+ #js-deploy-keys{ data: { endpoint: namespace_project_deploy_keys_path } }
diff --git a/app/views/projects/deploy_keys/edit.html.haml b/app/views/projects/deploy_keys/edit.html.haml
new file mode 100644
index 00000000000..37219f8d7ae
--- /dev/null
+++ b/app/views/projects/deploy_keys/edit.html.haml
@@ -0,0 +1,10 @@
+- page_title 'Edit Deploy Key'
+%h3.page-title Edit Deploy Key
+%hr
+
+%div
+ = form_for [@project.namespace.becomes(Namespace), @project, @deploy_key], html: { class: 'form-horizontal js-requires-input' } do |f|
+ = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
+ .form-actions
+ = f.submit 'Save changes', class: 'btn-save btn'
+ = link_to 'Cancel', namespace_project_settings_repository_path(@project.namespace, @project), class: 'btn btn-cancel'
diff --git a/app/views/projects/deploy_keys/new.html.haml b/app/views/projects/deploy_keys/new.html.haml
deleted file mode 100644
index 01fab3008a7..00000000000
--- a/app/views/projects/deploy_keys/new.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-- page_title "New Deploy Key"
-%h3.page-title New Deploy Key
-%hr
-
-= render 'form'
diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml
index 506246f2ee6..e2baaa625ae 100644
--- a/app/views/projects/deployments/_actions.haml
+++ b/app/views/projects/deployments/_actions.haml
@@ -8,6 +8,7 @@
= icon('caret-down')
%ul.dropdown-menu.dropdown-menu-align-right
- actions.each do |action|
+ - next unless can?(current_user, :update_build, action)
%li
= link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow' do
= custom_icon('icon_play')
diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml
index 170d786ecbf..4502c397d29 100644
--- a/app/views/projects/deployments/_commit.html.haml
+++ b/app/views/projects/deployments/_commit.html.haml
@@ -1,16 +1,17 @@
-.branch-commit
- - if deployment.ref
- .icon-container
- = deployment.tag? ? icon('tag') : icon('code-fork')
- = link_to deployment.ref, namespace_project_commits_path(@project.namespace, @project, deployment.ref), class: "monospace branch-name"
- .icon-container.commit-icon
- = custom_icon("icon_commit")
- = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-id monospace"
+.table-mobile-content
+ .branch-commit
+ - if deployment.ref
+ %span.icon-container
+ = deployment.tag? ? icon('tag') : icon('code-fork')
+ = link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name"
+ .icon-container.commit-icon
+ = custom_icon("icon_commit")
+ = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-sha"
- %p.commit-title
- %span
- - if commit_title = deployment.commit_title
- = author_avatar(deployment.commit, size: 20)
- = link_to_gfm commit_title, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-row-message"
- - else
- Cant find HEAD commit for this branch
+ %p.commit-title.flex-truncate-parent
+ %span.flex-truncate-child
+ - if commit_title = deployment.commit_title
+ = author_avatar(deployment.commit, size: 20)
+ = link_to_gfm commit_title, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-row-message"
+ - else
+ Cant find HEAD commit for this branch
diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml
index 260c9023daf..d956cb2cc1a 100644
--- a/app/views/projects/deployments/_deployment.html.haml
+++ b/app/views/projects/deployments/_deployment.html.haml
@@ -1,22 +1,26 @@
-%tr.deployment
- %td
- %strong ##{deployment.iid}
+.gl-responsive-table-row.deployment{ role: 'row' }
+ .table-section.section-10{ role: 'gridcell' }
+ .table-mobile-header{ role: 'rowheader' } ID
+ %strong.table-mobile-content ##{deployment.iid}
- %td
+ .table-section.section-40{ role: 'gridcell' }
+ .table-mobile-header{ role: 'rowheader' } Commit
= render 'projects/deployments/commit', deployment: deployment
- %td.build-column
+ .table-section.section-15.build-column{ role: 'gridcell' }
+ .table-mobile-header{ role: 'rowheader' } Job
- if deployment.deployable
- = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: 'build-link' do
+ = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: 'build-link table-mobile-content' do
#{deployment.deployable.name} (##{deployment.deployable.id})
- if deployment.user
by
= user_avatar(user: deployment.user, size: 20)
- %td
- #{time_ago_with_tooltip(deployment.created_at)}
+ .table-section.section-15{ role: 'gridcell' }
+ .table-mobile-header{ role: 'rowheader' } Created
+ %span.table-mobile-content= time_ago_with_tooltip(deployment.created_at)
- %td.hidden-xs
- .pull-right.btn-group
+ .table-section.section-20.environments-actions.table-button-footer{ role: 'gridcell' }
+ .btn-group.environment-action-buttons
= render 'projects/deployments/actions', deployment: deployment
= render 'projects/deployments/rollback', deployment: deployment
diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml
index c781e423c4d..59844bc00cd 100644
--- a/app/views/projects/diffs/_content.html.haml
+++ b/app/views/projects/diffs/_content.html.haml
@@ -1,12 +1,12 @@
-.diff-content.diff-wrap-lines
- -# Skip all non non-supported blobs
- - return unless blob.respond_to?(:text?)
+- blob = diff_file.blob
+
+.diff-content
- if diff_file.too_large?
.nothing-here-block This diff could not be displayed because it is too large.
- - elsif blob.too_large?
+ - elsif blob.truncated?
.nothing-here-block The file could not be displayed because it is too large.
- elsif blob.readable_text?
- - if !project.repository.diffable?(blob)
+ - if !diff_file.repository.diffable?(blob)
.nothing-here-block This diff was suppressed by a .gitattributes entry.
- elsif diff_file.collapsed?
- url = url_for(params.merge(action: :diff_for_path, old_path: diff_file.old_path, new_path: diff_file.new_path, file_identifier: diff_file.file_identifier))
@@ -15,20 +15,13 @@
%a.click-to-expand
Click to expand it.
- elsif diff_file.diff_lines.length > 0
- - total_lines = 0
- - if blob.lines.any?
- - total_lines = blob.lines.last.chomp == '' ? blob.lines.size - 1 : blob.lines.size
- - if diff_view == :parallel
- = render "projects/diffs/parallel_view", diff_file: diff_file, total_lines: total_lines
- - else
- = render "projects/diffs/text_file", diff_file: diff_file, total_lines: total_lines
+ = render "projects/diffs/viewers/text", diff_file: diff_file
- else
- if diff_file.mode_changed?
.nothing-here-block File mode changed
- - elsif diff_file.renamed_file
+ - elsif diff_file.renamed_file?
.nothing-here-block File moved
- elsif blob.image?
- - old_blob = diff_file.old_blob(diff_file.old_content_commit || @base_commit)
- = render "projects/diffs/image", diff_file: diff_file, old_file: old_blob, file: blob
+ = render "projects/diffs/viewers/image", diff_file: diff_file
- else
.nothing-here-block No preview for this file type
diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml
index 71a1b9e6c05..d538c4c86c8 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -5,8 +5,8 @@
.content-block.oneline-block.files-changed
.inline-parallel-buttons
- - if !expand_all_diffs? && diff_files.any? { |diff_file| diff_file.collapsed? }
- = link_to 'Expand all', url_for(params.merge(expand_all_diffs: 1, format: nil)), class: 'btn btn-default'
+ - if !diffs_expanded? && diff_files.any? { |diff_file| diff_file.collapsed? }
+ = link_to 'Expand all', url_for(params.merge(expanded: 1, format: nil)), class: 'btn btn-default'
- if show_whitespace_toggle
- if current_controller?(:commit)
= commit_diff_whitespace_link(diffs.project, @commit, class: 'hidden-xs')
@@ -23,12 +23,4 @@
= render 'projects/diffs/warning', diff_files: diffs
.files{ data: { can_create_note: can_create_note } }
- - diff_files.each_with_index do |diff_file|
- - diff_commit = commit_for_diff(diff_file)
- - blob = diff_file.blob(diff_commit)
- - next unless blob
- - blob.load_all_data!(diffs.project.repository) unless blob.too_large?
- - file_hash = hexdigest(diff_file.file_path)
-
- = render 'projects/diffs/file', file_hash: file_hash, project: diffs.project,
- diff_file: diff_file, diff_commit: diff_commit, blob: blob, environment: environment
+ = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment }
diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml
index f22b385fc0f..b5aea217384 100644
--- a/app/views/projects/diffs/_file.html.haml
+++ b/app/views/projects/diffs/_file.html.haml
@@ -1,10 +1,12 @@
- environment = local_assigns.fetch(:environment, nil)
-.diff-file.file-holder{ id: file_hash, data: diff_file_html_data(project, diff_file.file_path, diff_commit.id) }
+- file_hash = hexdigest(diff_file.file_path)
+.diff-file.file-holder{ id: file_hash, data: diff_file_html_data(project, diff_file.file_path, diff_file.content_sha) }
.js-file-title.file-title-flex-parent
.file-header-content
- = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_commit, project: project, url: "##{file_hash}"
+ = render "projects/diffs/file_header", diff_file: diff_file, url: "##{file_hash}"
- unless diff_file.submodule?
+ - blob = diff_file.blob
.file-actions.hidden-xs
- if blob.readable_text?
= link_to '#', class: 'js-toggle-diff-comments btn active has-tooltip', title: "Toggle comments for this file", disabled: @diff_notes_disabled do
@@ -15,9 +17,9 @@
= edit_blob_link(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path,
blob: blob, link_opts: link_opts)
- = view_file_button(diff_commit.id, diff_file.new_path, project)
- = view_on_environment_button(diff_commit.id, diff_file.new_path, environment) if environment
+ = view_file_button(diff_file.content_sha, diff_file.file_path, project)
+ = view_on_environment_button(diff_file.content_sha, diff_file.file_path, environment) if environment
= render 'projects/fork_suggestion'
- = render 'projects/diffs/content', diff_file: diff_file, diff_commit: diff_commit, blob: blob, project: project
+ = render 'projects/diffs/content', diff_file: diff_file
diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml
index 7d6b3701f95..73c316472e3 100644
--- a/app/views/projects/diffs/_file_header.html.haml
+++ b/app/views/projects/diffs/_file_header.html.haml
@@ -1,17 +1,22 @@
-%i.fa.diff-toggle-caret.fa-fw
-- if defined?(blob) && blob && diff_file.submodule?
+- show_toggle = local_assigns.fetch(:show_toggle, true)
+
+- if show_toggle
+ %i.fa.diff-toggle-caret.fa-fw
+
+- if diff_file.submodule?
+ - blob = diff_file.blob
%span
= icon('archive fw')
%strong.file-title-name
- = submodule_link(blob, diff_commit.id, project.repository)
+ = submodule_link(blob, diff_file.content_sha, diff_file.repository)
= copy_file_path_button(blob.path)
- else
= conditional_link_to url.present?, url do
= blob_icon diff_file.b_mode, diff_file.file_path
- - if diff_file.renamed_file
+ - if diff_file.renamed_file?
- old_path, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
%strong.file-title-name.has-tooltip{ data: { title: diff_file.old_path, container: 'body' } }
= old_path
@@ -19,12 +24,13 @@
%strong.file-title-name.has-tooltip{ data: { title: diff_file.new_path, container: 'body' } }
= new_path
- else
- %strong.file-title-name.has-tooltip{ data: { title: diff_file.new_path, container: 'body' } }
- = diff_file.new_path
- - if diff_file.deleted_file
+ %strong.file-title-name.has-tooltip{ data: { title: diff_file.file_path, container: 'body' } }
+ = diff_file.file_path
+
+ - if diff_file.deleted_file?
deleted
- = copy_file_path_button(diff_file.new_path)
+ = copy_file_path_button(diff_file.file_path)
- if diff_file.mode_changed?
%small
diff --git a/app/views/projects/diffs/_image.html.haml b/app/views/projects/diffs/_image.html.haml
deleted file mode 100644
index ca10921c5e2..00000000000
--- a/app/views/projects/diffs/_image.html.haml
+++ /dev/null
@@ -1,69 +0,0 @@
-- diff = diff_file.diff
-- file_raw_path = namespace_project_raw_path(@project.namespace, @project, tree_join(diff_file.new_ref, diff.new_path))
-// diff_refs will be nil for orphaned commits (e.g. first commit in repo)
-- if diff_file.old_ref
- - old_file_raw_path = namespace_project_raw_path(@project.namespace, @project, tree_join(diff_file.old_ref, diff.old_path))
-
-- if diff.renamed_file || diff.new_file || diff.deleted_file
- .image
- %span.wrap
- .frame{ class: image_diff_class(diff) }
- %img{ src: diff.deleted_file ? old_file_raw_path : file_raw_path, alt: diff.new_path }
- %p.image-info= number_to_human_size(file.size)
-- else
- .image
- .two-up.view
- %span.wrap
- .frame.deleted
- %a{ href: namespace_project_blob_path(@project.namespace, @project, tree_join(diff_file.old_ref, diff.old_path)) }
- %img{ src: old_file_raw_path, alt: diff.old_path }
- %p.image-info.hide
- %span.meta-filesize= number_to_human_size(old_file.size)
- |
- %b W:
- %span.meta-width
- |
- %b H:
- %span.meta-height
- %span.wrap
- .frame.added
- %a{ href: namespace_project_blob_path(@project.namespace, @project, tree_join(diff_file.new_ref, diff.new_path)) }
- %img{ src: file_raw_path, alt: diff.new_path }
- %p.image-info.hide
- %span.meta-filesize= number_to_human_size(file.size)
- |
- %b W:
- %span.meta-width
- |
- %b H:
- %span.meta-height
-
- .swipe.view.hide
- .swipe-frame
- .frame.deleted
- %img{ src: old_file_raw_path, alt: diff.old_path }
- .swipe-wrap
- .frame.added
- %img{ src: file_raw_path, alt: diff.new_path }
- %span.swipe-bar
- %span.top-handle
- %span.bottom-handle
-
- .onion-skin.view.hide
- .onion-skin-frame
- .frame.deleted
- %img{ src: old_file_raw_path, alt: diff.old_path }
- .frame.added
- %img{ src: file_raw_path, alt: diff.new_path }
- .controls
- .transparent
- .drag-track
- .dragger{ :style => "left: 0px;" }
- .opaque
-
-
- .view-modes.hide
- %ul.view-modes-menu
- %li.two-up{ data: { mode: 'two-up' } } 2-up
- %li.swipe{ data: { mode: 'swipe' } } Swipe
- %li.onion-skin{ data: { mode: 'onion-skin' } } Onion skin
diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml
index 7439b8a66f7..43708d22a0c 100644
--- a/app/views/projects/diffs/_line.html.haml
+++ b/app/views/projects/diffs/_line.html.haml
@@ -3,7 +3,7 @@
- discussions = local_assigns.fetch(:discussions, nil)
- type = line.type
- line_code = diff_file.line_code(line)
-- if discussions && !line.meta?
+- if discussions && line.discussable?
- line_discussions = discussions[line_code]
%tr.line_holder{ class: type, id: (line_code unless plain) }
- case type
diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml
index 45c95f7ab6a..8e5f4d2573d 100644
--- a/app/views/projects/diffs/_parallel_view.html.haml
+++ b/app/views/projects/diffs/_parallel_view.html.haml
@@ -49,7 +49,7 @@
- if discussions_left || discussions_right
= render "discussions/parallel_diff_discussion", discussions_left: discussions_left, discussions_right: discussions_right
- - if !diff_file.new_file && !diff_file.deleted_file && diff_file.diff_lines.any?
+ - if !diff_file.new_file? && !diff_file.deleted_file? && diff_file.diff_lines.any?
- last_line = diff_file.diff_lines.last
- if last_line.new_pos < total_lines
%tr.line_holder.parallel
diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml
index fd4f3c8d3cc..e69c7f20d49 100644
--- a/app/views/projects/diffs/_stats.html.haml
+++ b/app/views/projects/diffs/_stats.html.haml
@@ -12,19 +12,19 @@
- diff_files.each do |diff_file|
- file_hash = hexdigest(diff_file.file_path)
%li
- - if diff_file.deleted_file
+ - if diff_file.deleted_file?
%span.deleted-file
%a{ href: "##{file_hash}" }
%i.fa.fa-minus
= diff_file.old_path
- - elsif diff_file.renamed_file
+ - elsif diff_file.renamed_file?
%span.renamed-file
%a{ href: "##{file_hash}" }
%i.fa.fa-minus
= diff_file.old_path
&rarr;
= diff_file.new_path
- - elsif diff_file.new_file
+ - elsif diff_file.new_file?
%span.new-file
%a{ href: "##{file_hash}" }
%i.fa.fa-plus
diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml
index 5f3968b6709..e8a5e63e59e 100644
--- a/app/views/projects/diffs/_text_file.html.haml
+++ b/app/views/projects/diffs/_text_file.html.haml
@@ -3,13 +3,13 @@
.suppressed-container
%a.show-suppressed-diff.js-show-suppressed-diff Changes suppressed. Click to show.
-%table.text-file.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' }
+%table.text-file.diff-wrap-lines.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' }
= render partial: "projects/diffs/line",
collection: diff_file.highlighted_diff_lines,
as: :line,
locals: { diff_file: diff_file, discussions: @grouped_diff_discussions }
- - if !diff_file.new_file && !diff_file.deleted_file && diff_file.highlighted_diff_lines.any?
+ - if !diff_file.new_file? && !diff_file.deleted_file? && diff_file.highlighted_diff_lines.any?
- last_line = diff_file.highlighted_diff_lines.last
- if last_line.new_pos < total_lines
%tr.line_holder
diff --git a/app/views/projects/diffs/viewers/_image.html.haml b/app/views/projects/diffs/viewers/_image.html.haml
new file mode 100644
index 00000000000..ea75373581e
--- /dev/null
+++ b/app/views/projects/diffs/viewers/_image.html.haml
@@ -0,0 +1,68 @@
+- blob = diff_file.blob
+- old_blob = diff_file.old_blob
+- blob_raw_path = diff_file_blob_raw_path(diff_file)
+- old_blob_raw_path = diff_file_old_blob_raw_path(diff_file)
+
+- if diff_file.new_file? || diff_file.deleted_file?
+ .image
+ %span.wrap
+ .frame{ class: (diff_file.deleted_file? ? 'deleted' : 'added') }
+ %img{ src: blob_raw_path, alt: diff_file.file_path }
+ %p.image-info= number_to_human_size(blob.size)
+- else
+ .image
+ .two-up.view
+ %span.wrap
+ .frame.deleted
+ %a{ href: namespace_project_blob_path(@project.namespace, @project, tree_join(diff_file.old_content_sha, diff_file.old_path)) }
+ %img{ src: old_blob_raw_path, alt: diff_file.old_path }
+ %p.image-info.hide
+ %span.meta-filesize= number_to_human_size(old_blob.size)
+ |
+ %b W:
+ %span.meta-width
+ |
+ %b H:
+ %span.meta-height
+ %span.wrap
+ .frame.added
+ %a{ href: namespace_project_blob_path(@project.namespace, @project, tree_join(diff_file.content_sha, diff_file.new_path)) }
+ %img{ src: blob_raw_path, alt: diff_file.new_path }
+ %p.image-info.hide
+ %span.meta-filesize= number_to_human_size(blob.size)
+ |
+ %b W:
+ %span.meta-width
+ |
+ %b H:
+ %span.meta-height
+
+ .swipe.view.hide
+ .swipe-frame
+ .frame.deleted
+ %img{ src: old_blob_raw_path, alt: diff_file.old_path }
+ .swipe-wrap
+ .frame.added
+ %img{ src: blob_raw_path, alt: diff_file.new_path }
+ %span.swipe-bar
+ %span.top-handle
+ %span.bottom-handle
+
+ .onion-skin.view.hide
+ .onion-skin-frame
+ .frame.deleted
+ %img{ src: old_blob_raw_path, alt: diff_file.old_path }
+ .frame.added
+ %img{ src: blob_raw_path, alt: diff_file.new_path }
+ .controls
+ .transparent
+ .drag-track
+ .dragger{ :style => "left: 0px;" }
+ .opaque
+
+
+ .view-modes.hide
+ %ul.view-modes-menu
+ %li.two-up{ data: { mode: 'two-up' } } 2-up
+ %li.swipe{ data: { mode: 'swipe' } } Swipe
+ %li.onion-skin{ data: { mode: 'onion-skin' } } Onion skin
diff --git a/app/views/projects/diffs/viewers/_text.html.haml b/app/views/projects/diffs/viewers/_text.html.haml
new file mode 100644
index 00000000000..e4b89671724
--- /dev/null
+++ b/app/views/projects/diffs/viewers/_text.html.haml
@@ -0,0 +1,8 @@
+- blob = diff_file.blob
+- blob.load_all_data!(diff_file.repository)
+- total_lines = blob.lines.size
+- total_lines -= 1 if total_lines > 0 && blob.lines.last.blank?
+- if diff_view == :parallel
+ = render "projects/diffs/parallel_view", diff_file: diff_file, total_lines: total_lines
+- else
+ = render "projects/diffs/text_file", diff_file: diff_file, total_lines: total_lines
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 160345cfaa5..c3dab68cea5 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -40,9 +40,9 @@
.form_group.prepend-top-20.sharing-and-permissions
.row.js-visibility-select
.col-md-9
- %label.label-light
- = label_tag :project_visibility, 'Project Visibility', class: 'label-light'
- = link_to "(?)", help_page_path("public_access/public_access")
+ .label-light
+ = label_tag :project_visibility, 'Project Visibility', class: 'label-light', for: :project_visibility_level
+ = link_to icon('question-circle'), help_page_path("public_access/public_access")
%span.help-block
.col-md-3.visibility-select-container
= render('projects/visibility_select', model_method: :visibility_level, form: f, selected_level: @project.visibility_level)
@@ -65,7 +65,7 @@
.row
.col-md-9.project-feature.nested
= feature_fields.label :builds_access_level, "Pipelines", class: 'label-light'
- %span.help-block Submit, test and deploy your changes before merge
+ %span.help-block Build, test, and deploy your changes
.col-md-3
= project_feature_access_select(:builds_access_level)
@@ -92,14 +92,14 @@
.form-group
= render 'shared/allow_request_access', form: f
- if Gitlab.config.lfs.enabled && current_user.admin?
- .row
+ .row.js-lfs-enabled
.col-md-9
= f.label :lfs_enabled, 'LFS', class: 'label-light'
%span.help-block
Git Large File Storage
= link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
.col-md-3
- = f.select :lfs_enabled, [%w(Enabled true), %w(Disabled false)], {}, selected: @project.lfs_enabled?, class: 'pull-right form-control', data: { field: 'lfs_enabled' }
+ = f.select :lfs_enabled, [%w(Enabled true), %w(Disabled false)], {}, selected: @project.lfs_enabled?, class: 'pull-right form-control project-repo-select', data: { field: 'lfs_enabled' }
- if Gitlab.config.registry.enabled
@@ -246,14 +246,16 @@
.row.prepend-top-default
.col-lg-3
%h4.prepend-top-0.danger-title
- Transfer project
+ Transfer project to new group
+ %p.append-bottom-0
+ Please select the group you want to transfer this project to in the dropdown to the right.
.col-lg-9
- = form_for([@project.namespace.becomes(Namespace), @project], url: transfer_namespace_project_path(@project.namespace, @project), method: :put, remote: true) do |f|
+ = form_for([@project.namespace.becomes(Namespace), @project], url: transfer_namespace_project_path(@project.namespace, @project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } ) do |f|
.form-group
= label_tag :new_namespace_id, nil, class: 'label-light' do
- %span Namespace
+ %span Select a new namespace
.form-group
- = select_tag :new_namespace_id, namespaces_options(@project.namespace_id), { prompt: 'Choose a project namespace', class: 'select2' }
+ = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2'
%ul
%li Be careful. Changing the project's namespace can have unintended side effects.
%li You can only transfer the project to namespaces you manage.
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index 7315e671056..23aa4c29e69 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -3,7 +3,7 @@
= render "projects/pipelines/head"
%div{ class: container_class }
- .top-area.adjust
+ .row.top-area.adjust
.col-md-7
%h3.page-title= @environment.name
.col-md-5
@@ -13,7 +13,7 @@
= render 'projects/environments/metrics_button', environment: @environment
- if can?(current_user, :update_environment, @environment)
= link_to 'Edit', edit_namespace_project_environment_path(@project.namespace, @project, @environment), class: 'btn'
- - if can?(current_user, :create_deployment, @environment) && @environment.can_stop?
+ - if can?(current_user, :stop_environment, @environment)
= link_to 'Stop', stop_namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure you want to stop this environment?' }, class: 'btn btn-danger', method: :post
.environments-container
@@ -28,14 +28,12 @@
= link_to "Read more", help_page_path("ci/environments"), class: "btn btn-success"
- else
.table-holder
- %table.table.ci-table.environments
- %thead
- %tr
- %th ID
- %th Commit
- %th Job
- %th Created
- %th.hidden-xs
+ .ci-table.environments{ role: 'grid' }
+ .gl-responsive-table-row.table-row-header{ role: 'row' }
+ .table-section.section-10{ role: 'columnheader' } ID
+ .table-section.section-40{ role: 'columnheader' } Commit
+ .table-section.section-15{ role: 'columnheader' } Job
+ .table-section.section-15{ role: 'columnheader' } Created
= render @deployments
diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml
index 4cdb44325b3..8a409541fe5 100644
--- a/app/views/projects/find_file/show.html.haml
+++ b/app/views/projects/find_file/show.html.haml
@@ -1,4 +1,5 @@
- page_title "Find File", @ref
+= render "projects/commits/head"
.file-finder-holder.tree-holder.clearfix
.nav-block
@@ -9,7 +10,7 @@
= link_to namespace_project_tree_path(@project.namespace, @project, @ref) do
= @project.path
%li.file-finder
- %input#file_find.form-control.file-finder-input{ type: "text", placeholder: 'Find by path', autocomplete: 'off' }
+ %input#file_find.form-control.file-finder-input{ type: "text", placeholder: _('Find by path'), autocomplete: 'off' }
.tree-content-holder
.table-holder
diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
index f458646522c..b23bbadbdb4 100644
--- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
+++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
@@ -27,7 +27,7 @@
= custom_icon("icon_commit")
- if commit_sha
- = link_to generic_commit_status.short_sha, namespace_project_commit_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.sha), class: "commit-id monospace"
+ = link_to generic_commit_status.short_sha, namespace_project_commit_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.sha), class: "commit-sha"
- if retried
= icon('warning', class: 'text-warning has-tooltip', title: 'Status was retried.')
@@ -48,7 +48,7 @@
- if generic_commit_status.pipeline.user
= user_avatar(user: generic_commit_status.pipeline.user, size: 20)
- else
- %span.monospace API
+ %span.api API
- if admin
%td
diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml
new file mode 100644
index 00000000000..6962b223451
--- /dev/null
+++ b/app/views/projects/hook_logs/_index.html.haml
@@ -0,0 +1,37 @@
+.row.prepend-top-default.append-bottom-default
+ .col-lg-3
+ %h4.prepend-top-0
+ Recent Deliveries
+ %p When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong.
+ .col-lg-9
+ - if hook_logs.any?
+ %table.table
+ %thead
+ %tr
+ %th Status
+ %th Trigger
+ %th URL
+ %th Elapsed time
+ %th Request time
+ %th
+ - hook_logs.each do |hook_log|
+ %tr
+ %td
+ = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log }
+ %td.hidden-xs
+ %span.label.label-gray.deploy-project-label
+ = hook_log.trigger.singularize.titleize
+ %td
+ = truncate(hook_log.url, length: 50)
+ %td.light
+ #{number_with_precision(hook_log.execution_duration, precision: 2)} ms
+ %td.light
+ = time_ago_with_tooltip(hook_log.created_at)
+ %td
+ = link_to 'View details', namespace_project_hook_hook_log_path(project.namespace, project, hook, hook_log)
+
+ = paginate hook_logs, theme: 'gitlab'
+
+ - else
+ .settings-message.text-center
+ You don't have any webhooks deliveries
diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml
new file mode 100644
index 00000000000..2eabe92f8eb
--- /dev/null
+++ b/app/views/projects/hook_logs/show.html.haml
@@ -0,0 +1,11 @@
+= render 'projects/settings/head'
+
+.row.prepend-top-default.append-bottom-default
+ .col-lg-3
+ %h4.prepend-top-0
+ Request details
+ .col-lg-9
+
+ = link_to 'Resend Request', retry_namespace_project_hook_hook_log_path(@project.namespace, @project, @hook, @hook_log), class: "btn btn-default pull-right prepend-left-10"
+
+ = render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log }
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
index 7998713be1f..fd382c1d63f 100644
--- a/app/views/projects/hooks/edit.html.haml
+++ b/app/views/projects/hooks/edit.html.haml
@@ -1,3 +1,4 @@
+- page_title 'Integrations'
= render 'projects/settings/head'
.row.prepend-top-default
@@ -10,5 +11,12 @@
.col-lg-9.append-bottom-default
= form_for [@project.namespace.becomes(Namespace), @project, @hook], as: :hook, url: namespace_project_hook_path do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
+
= f.submit 'Save changes', class: 'btn btn-create'
+ = link_to 'Test hook', test_namespace_project_hook_path(@project.namespace, @project, @hook), class: 'btn btn-default'
+ = link_to 'Remove', namespace_project_hook_path(@project.namespace, @project, @hook), method: :delete, class: 'btn btn-remove pull-right', data: { confirm: 'Are you sure?' }
+
+%hr
+
+= render partial: 'projects/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs, project: @project }
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index 4dfda54feb5..8b095f4ca10 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -1,7 +1,7 @@
- content_for :note_actions do
- if can?(current_user, :update_issue, @issue)
- = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {no_turbolink: true, original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
- = 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'
+ = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
+ = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {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 'shared/notes/notes_with_form'
+ = render 'shared/notes/notes_with_form', :autocomplete => true
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index c184e0e0022..9e4e6934ca9 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -1,7 +1,7 @@
%li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id } }
.issue-box
- - if @bulk_edit
- .issue-check
+ - if @can_bulk_update
+ .issue-check.hidden
= check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected_issue"
.issue-info-container
.issue-title.title
diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml
index 1892ebb512f..8c9f6f3b4df 100644
--- a/app/views/projects/issues/_related_branches.html.haml
+++ b/app/views/projects/issues/_related_branches.html.haml
@@ -11,5 +11,4 @@
= render_pipeline_status(pipeline)
%span.related-branch-info
%strong
- = link_to namespace_project_compare_path(@project.namespace, @project, from: @project.default_branch, to: branch), class: "label-branch" do
- = branch
+ = link_to branch, namespace_project_compare_path(@project.namespace, @project, from: @project.default_branch, to: branch), class: "ref-name"
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index 60900e9d660..7183794ce72 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -1,5 +1,5 @@
- @no_container = true
-- @bulk_edit = can?(current_user, :admin_issue, @project)
+- @can_bulk_update = can?(current_user, :admin_issue, @project)
- page_title "Issues"
- new_issue_email = @project.new_issue_address(current_user)
@@ -20,6 +20,8 @@
.nav-controls
= link_to params.merge(rss_url_options), class: 'btn append-right-10 has-tooltip', title: 'Subscribe' do
= icon('rss')
+ - if @can_bulk_update
+ = button_tag "Edit Issues", class: "btn btn-default js-bulk-update-toggle"
= link_to new_namespace_project_issue_path(@project.namespace,
@project,
issue: { assignee_id: issues_finder.assignee.try(:id),
@@ -30,6 +32,9 @@
New issue
= render 'shared/issuable/search_bar', type: :issues
+ - if @can_bulk_update
+ = render 'shared/issuable/bulk_update_sidebar', type: :issues
+
.issues-holder
= render 'issues'
- if new_issue_email
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 100f430d8a2..5f92d020eef 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -2,6 +2,15 @@
- page_title "#{@issue.title} (#{@issue.to_reference})", "Issues"
- page_description @issue.description
- page_card_attributes @issue.card_attributes
+- can_update_issue = can?(current_user, :update_issue, @issue)
+- can_report_spam = @issue.submittable_as_spam_by?(current_user)
+
+- if defined?(@issue) && @issue.confidential?
+ .confidential-issue-warning{ data: { spy: 'affix' } }
+ %span.confidential-issue-text
+ #{confidential_icon(@issue)} This issue is confidential.
+ %a{ href: help_page_path('user/project/issues/confidential_issues'), target: '_blank' }
+ What are confidential issues?
.clearfix.detail-page-header
.issuable-header
@@ -17,7 +26,6 @@
= icon('angle-double-left')
.issuable-meta
- = confidential_icon(@issue)
= issuable_meta(@issue, @project, "Issue")
.issuable-actions
@@ -27,41 +35,42 @@
= icon('caret-down')
.dropdown-menu.dropdown-menu-align-right.hidden-lg
%ul
- %li
- = link_to 'New issue', new_namespace_project_issue_path(@project.namespace, @project), title: 'New issue', id: 'new_issue_link'
- - if can?(current_user, :update_issue, @issue)
+ - if can_update_issue
%li
- = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), data: {no_turbolink: true}, class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
+ = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'issuable-edit'
%li
- = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), data: {no_turbolink: true}, class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
+ = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
%li
- = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue)
- - if @issue.submittable_as_spam_by?(current_user)
+ = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
+ - if can_report_spam
%li
= link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam'
+ - if can_update_issue || can_report_spam
+ %li.divider
+ %li
+ = link_to 'New issue', new_namespace_project_issue_path(@project.namespace, @project), title: 'New issue', id: 'new_issue_link'
+ - if can_update_issue
+ = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit'
+ = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
+ = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
+ - if can_report_spam
+ = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam'
= link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do
New issue
- - if can?(current_user, :update_issue, @issue)
- = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
- = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
- - if @issue.submittable_as_spam_by?(current_user)
- = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam'
- = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit'
.issue-details.issuable-details
.detail-page-description.content-block
- .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' : '',
- "is-edited": @issue.is_edited?,
- } }
- .issue-title-entrypoint
- %h2.title.js-issue-title= markdown_field(@issue, :title)
+ %script#js-issuable-app-initial-data{ type: "application/json" }= issuable_initial_data(@issue)
+ #js-issuable-app
+ %h2.title= markdown_field(@issue, :title)
- if @issue.description.present?
.description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' }
- .wiki.js-issue-description= markdown_field(@issue, :description)
+ .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 js-issue-edited-ago')
+
#merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } }
// This element is filled in using JavaScript.
diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/jobs/_header.html.haml
index a0f8f105d9a..ad72ab5b199 100644
--- a/app/views/projects/builds/_header.html.haml
+++ b/app/views/projects/jobs/_header.html.haml
@@ -6,20 +6,18 @@
= render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title
%strong
Job
- = link_to namespace_project_build_path(@project.namespace, @project, @build), class: 'js-build-id' do
- \##{@build.id}
+ = link_to "##{@build.id}", namespace_project_job_path(@project.namespace, @project, @build), class: 'js-build-id'
in pipeline
- = link_to pipeline_path(pipeline) do
- %strong ##{pipeline.id}
- for commit
- = link_to namespace_project_commit_path(@project.namespace, @project, pipeline.sha) do
- %strong= pipeline.short_sha
+ %strong
+ = link_to "##{pipeline.id}", pipeline_path(pipeline)
+ for
+ %strong
+ = link_to pipeline.short_sha, namespace_project_commit_path(@project.namespace, @project, pipeline.sha), class: 'commit-sha'
from
- = link_to namespace_project_commits_path(@project.namespace, @project, @build.ref) do
- %code
- = @build.ref
+ %strong
+ = link_to @build.ref, project_ref_path(@project, @build.ref), class: 'ref-name'
- = render "projects/builds/user" if @build.user
+ = render "projects/jobs/user" if @build.user
= time_ago_with_tooltip(@build.created_at)
@@ -28,6 +26,6 @@
- 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
+ = link_to "Retry job", retry_namespace_project_job_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/jobs/_sidebar.html.haml
index 26c892d0fd2..09d4ddc243b 100644
--- a/app/views/projects/builds/_sidebar.html.haml
+++ b/app/views/projects/jobs/_sidebar.html.haml
@@ -1,6 +1,6 @@
- builds = @build.pipeline.builds.to_a
-%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "153", "spy" => "affix" } }
+%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } }
.block.build-sidebar-header.visible-xs-block.visible-sm-block.append-bottom-default
Job
%strong ##{@build.id}
@@ -30,21 +30,21 @@
- if @build.artifacts?
.btn-group.btn-group-justified{ role: :group }
- if @build.has_expiring_artifacts? && can?(current_user, :update_build, @build)
- = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do
+ = link_to keep_namespace_project_job_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do
Keep
- = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), rel: 'nofollow', download: '', class: 'btn btn-sm btn-default' do
+ = link_to download_namespace_project_job_artifacts_path(@project.namespace, @project, @build), rel: 'nofollow', download: '', class: 'btn btn-sm btn-default' do
Download
- if @build.artifacts_metadata?
- = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do
+ = link_to browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do
Browse
.block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) }
.title
Job details
- if can?(current_user, :update_build, @build) && @build.retryable?
- = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'pull-right retry-link', method: :post
+ = link_to "Retry job", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'pull-right retry-link', method: :post
- if @build.merge_request
%p.build-detail-row
%span.build-light-text Merge Request:
@@ -68,15 +68,8 @@
- elsif @build.runner
\##{@build.runner.id}
.btn-group.btn-group-justified{ role: :group }
- - if @build.has_trace?
- = link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default'
- if @build.active?
- = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post
- - if can?(current_user, :update_build, @project) && @build.erasable?
- = link_to erase_namespace_project_build_path(@project.namespace, @project, @build),
- class: "btn btn-sm btn-default", method: :post,
- data: { confirm: "Are you sure you want to erase this build?" } do
- Erase
+ = link_to "Cancel", cancel_namespace_project_job_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post
- if @build.trigger_request
.build-widget
@@ -118,7 +111,7 @@
%span.stage-selection More
= icon('chevron-down')
%ul.dropdown-menu
- - @build.pipeline.stages.each do |stage|
+ - @build.pipeline.legacy_stages.each do |stage|
%li
%a.stage-item= stage.name
@@ -126,7 +119,7 @@
- HasStatus::ORDERED_STATUSES.each do |build_status|
- builds.select{|build| build.status == build_status}.each do |build|
.build-job{ class: sidebar_build_class(build, @build), data: { stage: build.stage } }
- = link_to namespace_project_build_path(@project.namespace, @project, build) do
+ = link_to namespace_project_job_path(@project.namespace, @project, build) do
= icon('arrow-right')
%span{ class: "ci-status-icon-#{build.status}" }
= ci_icon_for_status(build.status)
@@ -137,6 +130,3 @@
= build.id
- if build.retried?
%i.fa.fa-refresh.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' }
-
-:javascript
- new Sidebar();
diff --git a/app/views/projects/builds/_table.html.haml b/app/views/projects/jobs/_table.html.haml
index 82806f022ee..82806f022ee 100644
--- a/app/views/projects/builds/_table.html.haml
+++ b/app/views/projects/jobs/_table.html.haml
diff --git a/app/views/projects/builds/_user.html.haml b/app/views/projects/jobs/_user.html.haml
index 83f299da651..83f299da651 100644
--- a/app/views/projects/builds/_user.html.haml
+++ b/app/views/projects/jobs/_user.html.haml
diff --git a/app/views/projects/builds/index.html.haml b/app/views/projects/jobs/index.html.haml
index 65162aacda1..a33e3978ee1 100644
--- a/app/views/projects/builds/index.html.haml
+++ b/app/views/projects/jobs/index.html.haml
@@ -4,17 +4,17 @@
%div{ class: container_class }
.top-area
- - build_path_proc = ->(scope) { project_builds_path(@project, scope: scope) }
+ - build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) }
= render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope
.nav-controls
- if can?(current_user, :update_build, @project)
- if @all_builds.running_or_pending.any?
- = link_to 'Cancel running', cancel_all_namespace_project_builds_path(@project.namespace, @project),
+ = link_to 'Cancel running', cancel_all_namespace_project_jobs_path(@project.namespace, @project),
data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
- unless @repository.gitlab_ci_yml
- = link_to 'Get started with CI/CD Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info'
+ = link_to 'Get started with Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info'
= link_to ci_lint_path, class: 'btn btn-default' do
%span CI lint
diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/jobs/show.html.haml
index 7cb2ec83cc7..0d10dfcef70 100644
--- a/app/views/projects/builds/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -8,7 +8,7 @@
- if @build.stuck?
- unless @build.any_runners_online?
- .bs-callout.bs-callout-warning
+ .bs-callout.bs-callout-warning.js-build-stuck
%p
- if no_runners_for_project?(@build.project)
This job is stuck, because the project doesn't have any runners online assigned to it.
@@ -26,7 +26,7 @@
Runners page
- if @build.starts_environment?
- .prepend-top-default
+ .prepend-top-default.js-environment-container
.environment-information
- if @build.outdated_deployment?
= ci_icon_for_status('success_with_warnings')
@@ -47,39 +47,51 @@
- if environment.try(:last_deployment)
and will overwrite the #{deployment_link(environment.last_deployment, text: 'latest deployment')}
- .prepend-top-default
+ .prepend-top-default.js-build-erased
- if @build.erased?
.erased.alert.alert-warning
- if @build.erased_by_user?
Job has been erased by #{link_to(@build.erased_by_name, user_path(@build.erased_by))} #{time_ago_with_tooltip(@build.erased_at)}
- else
Job has been erased #{time_ago_with_tooltip(@build.erased_at)}
- - else
- #js-build-scroll.scroll-controls
- .scroll-step
- %a.scroll-link.scroll-top{ href: '#up-build-trace', id: 'scroll-top', title: 'Scroll to top' }
- = custom_icon('scroll_up')
- = custom_icon('scroll_up_hover_active')
- %a.scroll-link.scroll-bottom{ href: '#down-build-trace', id: 'scroll-bottom', title: 'Scroll to bottom' }
- = custom_icon('scroll_down')
- = custom_icon('scroll_down_hover_active')
- - if @build.active?
- .autoscroll-container
- %span.status-message#autoscroll-status{ data: { state: 'disabled' } }
- %span.status-text Autoscroll active
- %i.status-icon
- = custom_icon('scroll_down_hover_active')
- #up-build-trace
- %pre.build-trace#build-trace
+
+ .prepend-top-default
+ .build-trace-container#build-trace
+ .top-bar.sticky
.js-truncated-info.truncated-info.hidden<
Showing last
%span.js-truncated-info-size.truncated-info-size><
KiB of log -
- %a.js-raw-link.raw-link{ :href => raw_namespace_project_build_path(@project.namespace, @project, @build) }>< Complete Raw
- %code.bash.js-build-output
- .build-loader-animation.js-build-refresh
+ %a.js-raw-link.raw-link{ href: raw_namespace_project_job_path(@project.namespace, @project, @build) }>< Complete Raw
+ .controllers
+ - if @build.has_trace?
+ = link_to raw_namespace_project_job_path(@project.namespace, @project, @build),
+ title: 'Open raw trace',
+ data: { placement: 'top', container: 'body' },
+ class: 'js-raw-link-controller has-tooltip' do
+ = icon('download')
+
+ - if can?(current_user, :update_build, @project) && @build.erasable?
+ = link_to erase_namespace_project_job_path(@project.namespace, @project, @build),
+ method: :post,
+ data: { confirm: 'Are you sure you want to erase this build?', placement: 'top', container: 'body' },
+ title: 'Erase Build',
+ class: 'has-tooltip js-erase-link' do
+ = icon('trash')
- #down-build-trace
+ %button.js-scroll-up.btn-scroll.btn-transparent.btn-blank.has-tooltip{ type: 'button',
+ disabled: true,
+ title: 'Scroll Up',
+ data: { placement: 'top', container: 'body'} }
+ = custom_icon('scroll_up')
+ %button.js-scroll-down.btn-scroll.btn-transparent.btn-blank.has-tooltip{ type: 'button',
+ disabled: true,
+ title: 'Scroll Down',
+ data: { placement: 'top', container: 'body'} }
+ = custom_icon('scroll_down')
+ .bash.sticky.js-scroll-container
+ %code.js-build-output
+ .build-loader-animation.js-build-refresh
= render "sidebar"
diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml
index 2e6420db212..b787edb3427 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 "shared/notes/notes_with_form"
+#notes= render "shared/notes/notes_with_form", :autocomplete => true
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index 11b7aaec704..c13110deb16 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -1,6 +1,6 @@
%li{ id: dom_id(merge_request), class: mr_css_classes(merge_request), data: { labels: merge_request.label_ids, id: merge_request.id } }
- - if @bulk_edit
- .issue-check
+ - if @can_bulk_update
+ .issue-check.hidden
= check_box_tag dom_id(merge_request, "selected"), nil, false, 'data-id' => merge_request.id, class: "selected_issue"
.issue-info-container
@@ -37,7 +37,7 @@
by #{link_to_member(@project, merge_request.author, avatar: false)}
- if merge_request.target_project.default_branch != merge_request.target_branch
&nbsp;
- = link_to namespace_project_commits_path(merge_request.project.namespace, merge_request.project, merge_request.target_branch) do
+ = link_to project_ref_path(merge_request.project, merge_request.target_branch), class: 'ref-name' do
= icon('code-fork')
= merge_request.target_branch
diff --git a/app/views/projects/merge_requests/_new_compare.html.haml b/app/views/projects/merge_requests/_new_compare.html.haml
index 9cf24e10842..0f37abb579c 100644
--- a/app/views/projects/merge_requests/_new_compare.html.haml
+++ b/app/views/projects/merge_requests/_new_compare.html.haml
@@ -21,8 +21,8 @@
selected: f.object.source_project_id
.merge-request-select.dropdown
= f.hidden_field :source_branch
- = dropdown_toggle f.object.source_branch || "Select source branch", { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch" }
- .dropdown-menu.dropdown-menu-selectable.dropdown-source-branch
+ = dropdown_toggle f.object.source_branch || "Select source branch", { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch git-revision-dropdown-toggle" }
+ .dropdown-menu.dropdown-menu-selectable.dropdown-source-branch.git-revision-dropdown
= dropdown_title("Select source branch")
= dropdown_filter("Search branches")
= dropdown_content do
@@ -51,8 +51,8 @@
selected: f.object.target_project_id
.merge-request-select.dropdown
= f.hidden_field :target_branch
- = dropdown_toggle f.object.target_branch, { toggle: "dropdown", field_name: "#{f.object_name}[target_branch]" }, { toggle_class: "js-compare-dropdown js-target-branch" }
- .dropdown-menu.dropdown-menu-selectable.dropdown-target-branch.js-target-branch-dropdown
+ = dropdown_toggle f.object.target_branch, { toggle: "dropdown", field_name: "#{f.object_name}[target_branch]" }, { toggle_class: "js-compare-dropdown js-target-branch git-revision-dropdown-toggle" }
+ .dropdown-menu.dropdown-menu-selectable.dropdown-target-branch.js-target-branch-dropdown.git-revision-dropdown
= dropdown_title("Select target branch")
= dropdown_filter("Search branches")
= dropdown_content do
diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/_new_submit.html.haml
index da79ca2ee75..e3ecbee5490 100644
--- a/app/views/projects/merge_requests/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/_new_submit.html.haml
@@ -3,9 +3,9 @@
%p.slead
- source_title, target_title = format_mr_branch_names(@merge_request)
From
- %strong.label-branch= source_title
+ %strong.ref-name= source_title
%span into
- %strong.label-branch= target_title
+ %strong.ref-name= target_title
%span.pull-right
= link_to 'Change branches', mr_change_branches_path(@merge_request)
diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml
index 502220232a1..6d75a9f34a3 100644
--- a/app/views/projects/merge_requests/index.html.haml
+++ b/app/views/projects/merge_requests/index.html.haml
@@ -1,21 +1,25 @@
- @no_container = true
-- @bulk_edit = can?(current_user, :admin_merge_request, @project)
+- @can_bulk_update = can?(current_user, :admin_merge_request, @project)
- page_title "Merge Requests"
- unless @project.default_issues_tracker?
= content_for :sub_nav do
= render "projects/merge_requests/head"
-= render 'projects/last_push'
- content_for :page_specific_javascripts do
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'filtered_search'
+
+= render 'projects/last_push'
+
- if @project.merge_requests.exists?
%div{ class: container_class }
.top-area
= render 'shared/issuable/nav', type: :merge_requests
.nav-controls
+ - if @can_bulk_update
+ = button_tag "Edit Merge Requests", class: "btn js-bulk-update-toggle"
- merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
- if merge_project
= link_to new_namespace_project_merge_request_path(merge_project.namespace, merge_project), class: "btn btn-new", title: "New merge request" do
@@ -23,6 +27,9 @@
= render 'shared/issuable/search_bar', type: :merge_requests
+ - if @can_bulk_update
+ = render 'shared/issuable/bulk_update_sidebar', type: :merge_requests
+
.merge-requests-holder
= render 'merge_requests'
- else
diff --git a/app/views/projects/merge_requests/show/_how_to_merge.html.haml b/app/views/projects/merge_requests/show/_how_to_merge.html.haml
index f3372c7657f..766cb272bec 100644
--- a/app/views/projects/merge_requests/show/_how_to_merge.html.haml
+++ b/app/views/projects/merge_requests/show/_how_to_merge.html.haml
@@ -49,7 +49,7 @@
%strong Tip:
= succeed '.' do
You can also checkout merge requests locally by
- = link_to 'following these guidelines', help_page_path('user/project/merge_requests.md', anchor: "checkout-merge-requests-locally"), target: '_blank', rel: 'noopener noreferrer'
+ = link_to 'following these guidelines', help_page_path('user/project/merge_requests/index.md', anchor: "checkout-merge-requests-locally"), target: '_blank', rel: 'noopener noreferrer'
:javascript
$(function(){
diff --git a/app/views/projects/merge_requests/show/_mr_title.html.haml b/app/views/projects/merge_requests/show/_mr_title.html.haml
index e7c5bca6a37..d9428b8562e 100644
--- a/app/views/projects/merge_requests/show/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/show/_mr_title.html.haml
@@ -13,7 +13,7 @@
= icon('angle-double-left')
.issuable-meta
- = issuable_meta(@merge_request, @project, "Merge Request")
+ = issuable_meta(@merge_request, @project, "Merge request")
- if can?(current_user, :update_merge_request, @merge_request)
.issuable-actions
diff --git a/app/views/projects/merge_requests/show/_versions.html.haml b/app/views/projects/merge_requests/show/_versions.html.haml
index 11b0c55be0b..0999b95c9c9 100644
--- a/app/views/projects/merge_requests/show/_versions.html.haml
+++ b/app/views/projects/merge_requests/show/_versions.html.haml
@@ -20,25 +20,27 @@
- @merge_request_diffs.each do |merge_request_diff|
%li
= link_to merge_request_version_path(@project, @merge_request, merge_request_diff, @start_sha), class: ('is-active' if merge_request_diff == @merge_request_diff) do
- %strong
- - if merge_request_diff.latest?
- latest version
- - else
- version #{version_index(merge_request_diff)}
- .monospace= short_sha(merge_request_diff.head_commit_sha)
- %small
- #{number_with_delimiter(merge_request_diff.commits_count)} #{'commit'.pluralize(merge_request_diff.commits_count)},
- = time_ago_with_tooltip(merge_request_diff.created_at)
+ %div
+ %strong
+ - if merge_request_diff.latest?
+ latest version
+ - else
+ version #{version_index(merge_request_diff)}
+ %div
+ %small.commit-sha= short_sha(merge_request_diff.head_commit_sha)
+ %div
+ %small
+ #{number_with_delimiter(merge_request_diff.commits_count)} #{'commit'.pluralize(merge_request_diff.commits_count)},
+ = time_ago_with_tooltip(merge_request_diff.created_at)
- if @merge_request_diff.base_commit_sha
and
%span.dropdown.inline.mr-version-compare-dropdown
%a.btn.btn-default.dropdown-toggle{ data: {toggle: :dropdown} }
- %span
- - if @start_version
- version #{version_index(@start_version)}
- - else
- #{@merge_request.target_branch}
+ - if @start_version
+ version #{version_index(@start_version)}
+ - else
+ %span.ref-name= @merge_request.target_branch
= icon('caret-down')
.dropdown-menu.dropdown-select.dropdown-menu-selectable
.dropdown-title
@@ -50,19 +52,25 @@
- @comparable_diffs.each do |merge_request_diff|
%li
= link_to merge_request_version_path(@project, @merge_request, @merge_request_diff, merge_request_diff.head_commit_sha), class: ('is-active' if merge_request_diff == @start_version) do
- %strong
- - if merge_request_diff.latest?
- latest version
- - else
- version #{version_index(merge_request_diff)}
- .monospace= short_sha(merge_request_diff.head_commit_sha)
- %small
- = time_ago_with_tooltip(merge_request_diff.created_at)
+ %div
+ %strong
+ - if merge_request_diff.latest?
+ latest version
+ - else
+ version #{version_index(merge_request_diff)}
+ %div
+ %small.commit-sha= short_sha(merge_request_diff.head_commit_sha)
+ %div
+ %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_version) do
- %strong
- #{@merge_request.target_branch} (base)
- .monospace= short_sha(@merge_request_diff.base_commit_sha)
+ %div
+ %strong
+ %span.ref-name= @merge_request.target_branch
+ (base)
+ %div
+ %strong.commit-sha= short_sha(@merge_request_diff.base_commit_sha)
- if different_base?(@start_version, @merge_request_diff)
.content-block
@@ -83,7 +91,7 @@
comparing two versions
- else
viewing an old version
- of this merge request.
+ of the diff.
.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/new.html.haml b/app/views/projects/new.html.haml
index 9e292729425..7b8be58554a 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -30,7 +30,7 @@
#{root_url}#{current_user.username}/
= f.hidden_field :namespace_id, value: current_user.namespace_id
.form-group.col-xs-12.col-sm-6.project-path
- = f.label :namespace_id, class: 'label-light' do
+ = f.label :path, class: 'label-light' do
%span
Project name
= f.text_field :path, placeholder: "my-awesome-project", class: "form-control", tabindex: 2, autofocus: true, required: true
@@ -95,7 +95,7 @@
.form-group.project-visibility-level-holder
= f.label :visibility_level, class: 'label-light' do
Visibility Level
- = link_to icon('question-circle'), help_page_path("public_access/public_access")
+ = link_to icon('question-circle'), help_page_path("public_access/public_access"), aria: { label: 'Documentation for Visibility Level' }
= render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false
= f.submit 'Create project', class: "btn btn-create project-submit", tabindex: 4
diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml
index 720957e8336..1cf286ddc40 100644
--- a/app/views/projects/no_repo.html.haml
+++ b/app/views/projects/no_repo.html.haml
@@ -1,22 +1,22 @@
%h2
%i.fa.fa-warning
- No repository
+ #{ _('No repository') }
%p.slead
- The repository for this project does not exist.
+ #{ _('The repository for this project does not exist.') }
%br
- This means you can not push code until you create an empty repository or import existing one.
+ #{ _('This means you can not push code until you create an empty repository or import existing one.') }
%hr
.no-repo-actions
= link_to namespace_project_repository_path(@project.namespace, @project), method: :post, class: 'btn btn-primary' do
- Create empty bare repository
+ #{ _('Create empty bare repository') }
%strong.prepend-left-10.append-right-10 or
= link_to new_namespace_project_import_path(@project.namespace, @project), class: 'btn' do
- Import repository
+ #{ _('Import repository') }
- if can? current_user, :remove_project, @project
.prepend-top-20
- = link_to 'Remove project', project_path(@project), data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-remove pull-right"
+ = link_to _('Remove project'), project_path(@project), data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-remove pull-right"
diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml
index d70ec8a6062..9c42be4e0ff 100644
--- a/app/views/projects/notes/_actions.html.haml
+++ b/app/views/projects/notes/_actions.html.haml
@@ -31,14 +31,10 @@
- if current_user
- if note.emoji_awardable?
- user_authored = note.user_authored?(current_user)
- = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do
+ = link_to '#', title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do
= icon('spinner spin')
%span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face')
%span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley')
%span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile')
- - if note_editable
- = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do
- = icon('pencil', class: 'link-highlight')
- = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do
- = icon('trash-o', class: 'danger-highlight')
+ = render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable
diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml
new file mode 100644
index 00000000000..e0d45054854
--- /dev/null
+++ b/app/views/projects/notes/_more_actions_dropdown.html.haml
@@ -0,0 +1,14 @@
+.dropdown.more-actions
+ = button_tag title: 'More actions', class: 'note-action-button more-actions-toggle has-tooltip btn btn-transparent', data: { toggle: 'dropdown', container: 'body' } do
+ = icon('ellipsis-v', class: 'icon')
+ %ul.dropdown-menu.more-actions-dropdown.dropdown-open-left
+ %li
+ = button_tag 'Edit comment', class: 'js-note-edit btn btn-transparent'
+ %li.divider
+ %li
+ = link_to new_abuse_report_path(user_id: note.author.id, ref_url: noteable_note_url(note)) do
+ Report as abuse
+ - if note_editable
+ %li
+ = link_to note_url(note), method: :delete, data: { confirm: 'Are you sure you want to delete this comment?' }, remote: true, class: 'js-note-delete' do
+ %span.text-danger Delete comment
diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
index 075ecee4343..7bde839e26f 100644
--- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
+++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
@@ -4,7 +4,8 @@
= pipeline_schedule.description
%td.branch-name-cell
= icon('code-fork')
- = link_to pipeline_schedule.ref, namespace_project_commits_path(@project.namespace, @project, pipeline_schedule.ref), class: "branch-name"
+ - if pipeline_schedule.ref
+ = link_to pipeline_schedule.ref, project_ref_path(@project, pipeline_schedule.ref), class: "ref-name"
%td
- if pipeline_schedule.last_pipeline
.status-icon-container{ class: "ci-status-icon-#{pipeline_schedule.last_pipeline.status}" }
@@ -15,7 +16,7 @@
None
%td.next-run-cell
- if pipeline_schedule.active?
- = time_ago_with_tooltip(pipeline_schedule.next_run_at)
+ = time_ago_with_tooltip(pipeline_schedule.real_next_run)
- else
Inactive
%td
diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml
index db9d77dba16..a33da149c62 100644
--- a/app/views/projects/pipelines/_head.html.haml
+++ b/app/views/projects/pipelines/_head.html.haml
@@ -11,7 +11,7 @@
- if project_nav_tab? :builds
= nav_link(controller: [:builds, :artifacts]) do
- = link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
+ = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
%span
Jobs
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index ab6baaf35b6..673c3370b62 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -1,18 +1,4 @@
-.page-content-header
- .header-main-content
- = render 'ci/status/badge', status: @pipeline.detailed_status(current_user), title: @pipeline.status_title
- %strong Pipeline ##{@pipeline.id}
- triggered #{time_ago_with_tooltip(@pipeline.created_at)}
- - if @pipeline.user
- by
- = user_avatar(user: @pipeline.user, size: 24)
- = user_link(@pipeline.user)
- .header-action-buttons
- - if can?(current_user, :update_pipeline, @pipeline.project)
- - if @pipeline.retryable?
- = link_to "Retry", retry_namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'js-retry-button btn btn-inverted-secondary', method: :post
- - if @pipeline.cancelable?
- = link_to "Cancel running", cancel_namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
+#js-pipeline-header-vue.pipeline-header-container
- if @commit
.commit-box
@@ -30,7 +16,7 @@
= pluralize @pipeline.statuses.count(:id), "job"
- if @pipeline.ref
from
- = link_to @pipeline.ref, namespace_project_commits_path(@project.namespace, @project, @pipeline.ref), class: "monospace"
+ = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name"
- if @pipeline.duration
in
= time_interval_in_words(@pipeline.duration)
@@ -40,10 +26,10 @@
.well-segment.branch-info
.icon-container.commit-icon
= custom_icon("icon_commit")
- = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace js-details-short"
+ = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "commit-sha js-details-short"
= link_to("#", class: "js-details-expand hidden-xs hidden-sm") do
%span.text-expander
\...
%span.js-details-content.hide
- = link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace commit-hash-full"
+ = link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "commit-sha commit-hash-full"
= clipboard_button(text: @pipeline.sha, title: "Copy commit SHA to clipboard")
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 075ddc0025c..85550e8fd32 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -1,9 +1,5 @@
- failed_builds = @pipeline.statuses.latest.failed
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('pipelines_graph')
-
.tabs-holder
%ul.pipelines-tabs.nav-links.no-top.no-bottom
%li.js-pipeline-tab-link
@@ -21,7 +17,7 @@
.tab-content
#js-tab-pipeline.tab-pane
- #js-pipeline-graph-vue{ data: { endpoint: namespace_project_pipeline_path(@project.namespace, @project, @pipeline, format: :json) } }
+ #js-pipeline-graph-vue
#js-tab-builds.tab-pane
- if pipeline.yaml_errors.present?
@@ -46,7 +42,7 @@
%th
%th Coverage
%th
- = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage
+ = render partial: "projects/stage/stage", collection: pipeline.legacy_stages, as: :stage
- if failed_builds.present?
#js-tab-failures.build-failures.tab-pane
- failed_builds.each_with_index do |build, index|
@@ -55,5 +51,5 @@
%span.stage
= build.stage.titleize
%span.build-name
- = link_to build.name, pipeline_build_url(pipeline, build)
+ = link_to build.name, pipeline_job_url(pipeline, build)
%pre.build-log= build_summary(build, skip: index >= 10)
diff --git a/app/views/projects/pipelines/charts/_overall.haml b/app/views/projects/pipelines/charts/_overall.haml
index edc4f7b079f..0b7e3d22dd7 100644
--- a/app/views/projects/pipelines/charts/_overall.haml
+++ b/app/views/projects/pipelines/charts/_overall.haml
@@ -2,13 +2,13 @@
%ul
%li
Total:
- %strong= pluralize @project.builds.count(:all), 'build'
+ %strong= pluralize @project.builds.count(:all), 'job'
%li
Successful:
- %strong= pluralize @project.builds.success.count(:all), 'build'
+ %strong= pluralize @project.builds.success.count(:all), 'job'
%li
Failed:
- %strong= pluralize @project.builds.failed.count(:all), 'build'
+ %strong= pluralize @project.builds.failed.count(:all), 'job'
%li
Success ratio:
%strong
diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml
index 14a270a3039..71a8e490c3e 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -11,8 +11,8 @@
.col-sm-10
= hidden_field_tag 'pipeline[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",
+ options: { toggle_class: 'js-branch-select wide git-revision-dropdown-toggle',
+ filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: "Search branches",
data: { selected: params[:ref] || @project.default_branch, field_name: 'pipeline[ref]' } })
.help-block Existing branch name, tag
.form-actions
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index 49c1d886423..b39453a50fb 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -7,3 +7,9 @@
= render "projects/pipelines/info"
= render "projects/pipelines/with_tabs", pipeline: @pipeline
+
+.js-pipeline-details-vue{ data: { endpoint: namespace_project_pipeline_path(@project.namespace, @project, @pipeline, format: :json) } }
+
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag('common_vue')
+ = webpack_bundle_tag('pipelines_details')
diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml
index a3f84476dea..3b17daeb6da 100644
--- a/app/views/projects/pipelines_settings/_show.html.haml
+++ b/app/views/projects/pipelines_settings/_show.html.haml
@@ -1,14 +1,14 @@
.row.prepend-top-default
.col-lg-3.profile-settings-sidebar
%h4.prepend-top-0
- CI/CD Pipelines
+ Pipelines
.col-lg-9
= form_for @project, url: namespace_project_pipelines_settings_path(@project.namespace.becomes(Namespace), @project) do |f|
%fieldset.builds-feature
- unless @repository.gitlab_ci_yml
.form-group
%p Pipelines need to be configured before you can begin using Continuous Integration.
- = link_to 'Get started with CI/CD Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info'
+ = link_to 'Get started with Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info'
%hr
.form-group.append-bottom-default
= f.label :runners_token, "Runner token", class: 'label-light'
@@ -42,7 +42,7 @@
= f.label :build_timeout_in_minutes, 'Timeout', class: 'label-light'
= f.number_field :build_timeout_in_minutes, class: 'form-control', min: '0'
%p.help-block
- Per job in minutes. If a job passes this threshold, it will be marked as failed.
+ Per job in minutes. If a job passes this threshold, it will be marked as failed
= link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank'
%hr
diff --git a/app/views/projects/protected_branches/_dropdown.html.haml b/app/views/projects/protected_branches/_dropdown.html.haml
index 5af0cc7a2f3..6e9c473494e 100644
--- a/app/views/projects/protected_branches/_dropdown.html.haml
+++ b/app/views/projects/protected_branches/_dropdown.html.haml
@@ -1,8 +1,8 @@
= f.hidden_field(:name)
= dropdown_tag('Select branch or create wildcard',
- options: { toggle_class: 'js-protected-branch-select js-filter-submit wide',
- filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search protected branches",
+ options: { toggle_class: 'js-protected-branch-select js-filter-submit wide git-revision-dropdown-toggle',
+ filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: "Search protected branches",
footer_content: true,
data: { show_no: true, show_any: true, show_upcoming: true,
selected: params[:protected_branch_name],
diff --git a/app/views/projects/protected_branches/_index.html.haml b/app/views/projects/protected_branches/_index.html.haml
index 2d8c519c025..9af67649741 100644
--- a/app/views/projects/protected_branches/_index.html.haml
+++ b/app/views/projects/protected_branches/_index.html.haml
@@ -1,20 +1,25 @@
+- expanded = Rails.env.test?
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('protected_branches')
-.row.prepend-top-default.append-bottom-default
- .col-lg-3
- %h4.prepend-top-0
+%section.settings
+ .settings-header
+ %h4
Protected Branches
- %p Keep stable branches secure and force developers to use merge requests.
- %p.prepend-top-20
+ %button.btn.js-settings-toggle
+ = expanded ? 'Close' : 'Expand'
+ %p
+ Keep stable branches secure and force developers to use merge requests.
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
+ %p
By default, protected branches are designed to:
%ul
%li prevent their creation, if not already created, from everybody except Masters
%li prevent pushes from everybody except Masters
%li prevent <strong>anyone</strong> from force pushing to the branch
%li prevent <strong>anyone</strong> from deleting the branch
- %p.append-bottom-0 Read more about #{link_to "protected branches", help_page_path("user/project/protected_branches"), class: "underlined-link"} and #{link_to "project permissions", help_page_path("user/permissions"), class: "underlined-link"}.
- .col-lg-9
+ %p Read more about #{link_to "protected branches", help_page_path("user/project/protected_branches"), class: "underlined-link"} and #{link_to "project permissions", help_page_path("user/permissions"), class: "underlined-link"}.
+
- if can? current_user, :admin_project, @project
= render 'projects/protected_branches/create_protected_branch'
diff --git a/app/views/projects/protected_branches/_matching_branch.html.haml b/app/views/projects/protected_branches/_matching_branch.html.haml
index 8a5332ca5bb..27896272733 100644
--- a/app/views/projects/protected_branches/_matching_branch.html.haml
+++ b/app/views/projects/protected_branches/_matching_branch.html.haml
@@ -1,9 +1,10 @@
%tr
%td
- = link_to matching_branch.name, namespace_project_tree_path(@project.namespace, @project, matching_branch.name)
+ = link_to matching_branch.name, project_ref_path(@project, matching_branch.name), class: 'ref-name'
+
- if @project.root_ref?(matching_branch.name)
%span.label.label-info.prepend-left-5 default
%td
- commit = @project.commit(matching_branch.name)
- = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id')
+ = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha')
= time_ago_with_tooltip(commit.committed_date)
diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml
index b2a6b8469a3..0f80de94392 100644
--- a/app/views/projects/protected_branches/_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_protected_branch.html.haml
@@ -1,6 +1,7 @@
%tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } }
%td
- = protected_branch.name
+ %span.ref-name= protected_branch.name
+
- if @project.root_ref?(protected_branch.name)
%span.label.label-info.prepend-left-5 default
%td
@@ -9,7 +10,7 @@
= link_to pluralize(matching_branches.count, "matching branch"), namespace_project_protected_branch_path(@project.namespace, @project, protected_branch)
- else
- if commit = protected_branch.commit
- = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id')
+ = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha')
= time_ago_with_tooltip(commit.committed_date)
- else
(branch was removed from repository)
diff --git a/app/views/projects/protected_branches/show.html.haml b/app/views/projects/protected_branches/show.html.haml
index f8cfe5e4b11..a806a0756ec 100644
--- a/app/views/projects/protected_branches/show.html.haml
+++ b/app/views/projects/protected_branches/show.html.haml
@@ -2,7 +2,7 @@
.row.prepend-top-default.append-bottom-default
.col-lg-3
- %h4.prepend-top-0
+ %h4.prepend-top-0.ref-name
= @protected_ref.name
.col-lg-9
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 af9a080f0a2..dd5b346d922 100644
--- a/app/views/projects/protected_tags/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml
@@ -1,4 +1,4 @@
-= form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'js-new-protected-tag' } do |f|
+= form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'new-protected-tag js-new-protected-tag' } do |f|
.panel.panel-default
.panel-heading
%h3.panel-title
diff --git a/app/views/projects/protected_tags/_dropdown.html.haml b/app/views/projects/protected_tags/_dropdown.html.haml
index c50515cfe06..9b6923210f7 100644
--- a/app/views/projects/protected_tags/_dropdown.html.haml
+++ b/app/views/projects/protected_tags/_dropdown.html.haml
@@ -1,8 +1,8 @@
= f.hidden_field(:name)
= 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 capitalize-header", placeholder: "Search protected tag",
+ options: { toggle_class: 'js-protected-tag-select js-filter-submit wide git-revision-dropdown-toggle',
+ filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header git-revision-dropdown", placeholder: "Search protected tags",
footer_content: true,
data: { show_no: true, show_any: true, show_upcoming: true,
selected: params[:protected_tag_name],
@@ -10,6 +10,6 @@
%ul.dropdown-footer-list
%li
- = link_to '#', title: "New Protected Tag", class: "create-new-protected-tag" do
+ %button{ class: "create-new-protected-tag-button js-create-new-protected-tag", title: "New Protected Tag" }
Create wildcard
%code
diff --git a/app/views/projects/protected_tags/_index.html.haml b/app/views/projects/protected_tags/_index.html.haml
index 0bfb1ad191d..976e1d7e93f 100644
--- a/app/views/projects/protected_tags/_index.html.haml
+++ b/app/views/projects/protected_tags/_index.html.haml
@@ -1,17 +1,25 @@
+- expanded = Rails.env.test?
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('protected_tags')
-.row.prepend-top-default.append-bottom-default
- .col-lg-3
- %h4.prepend-top-0
- Protected tags
- %p.prepend-top-20
- By default, Protected tags are designed to:
+%section.settings
+ .settings-header
+ %h4
+ Protected Tags
+ %button.btn.js-settings-toggle
+ = expanded ? 'Close' : 'Expand'
+ %p
+ Limit access to creating and updating tags.
+ .settings-content.no-animate{ class: ('expanded' if expanded) }
+ %p
+ By default, protected tags are designed to:
%ul
%li Prevent tag creation by everybody except Masters
%li Prevent <strong>anyone</strong> from updating the tag
%li Prevent <strong>anyone</strong> from deleting the tag
- .col-lg-9
+
+ %p Read more about #{link_to "protected tags", help_page_path("user/project/protected_tags"), class: "underlined-link"}.
+
- if can? current_user, :admin_project, @project
= render 'projects/protected_tags/create_protected_tag'
diff --git a/app/views/projects/protected_tags/_matching_tag.html.haml b/app/views/projects/protected_tags/_matching_tag.html.haml
index 97e5cd6f9d2..f17353df122 100644
--- a/app/views/projects/protected_tags/_matching_tag.html.haml
+++ b/app/views/projects/protected_tags/_matching_tag.html.haml
@@ -1,9 +1,10 @@
%tr
%td
- = link_to matching_tag.name, namespace_project_tree_path(@project.namespace, @project, matching_tag.name)
+ = link_to matching_tag.name, project_ref_path(@project, matching_tag.name), class: 'ref-name'
+
- if @project.root_ref?(matching_tag.name)
%span.label.label-info.prepend-left-5 default
%td
- commit = @project.commit(matching_tag.name)
- = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id')
+ = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha')
= time_ago_with_tooltip(commit.committed_date)
diff --git a/app/views/projects/protected_tags/_protected_tag.html.haml b/app/views/projects/protected_tags/_protected_tag.html.haml
index 26bd3a1f5ed..f11ce0483a9 100644
--- a/app/views/projects/protected_tags/_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/_protected_tag.html.haml
@@ -1,6 +1,7 @@
%tr.js-protected-tag-edit-form{ data: { url: namespace_project_protected_tag_path(@project.namespace, @project, protected_tag) } }
%td
- = protected_tag.name
+ %span.ref-name= protected_tag.name
+
- if @project.root_ref?(protected_tag.name)
%span.label.label-info.prepend-left-5 default
%td
@@ -9,7 +10,7 @@
= link_to pluralize(matching_tags.count, "matching tag"), namespace_project_protected_tag_path(@project.namespace, @project, protected_tag)
- else
- if commit = protected_tag.commit
- = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id')
+ = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha')
= time_ago_with_tooltip(commit.committed_date)
- else
(tag was removed from repository)
@@ -18,4 +19,4 @@
- if can_admin_project
%td
- = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag], data: { confirm: 'tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning'
+ = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag], data: { confirm: 'Tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning'
diff --git a/app/views/projects/protected_tags/_tags_list.html.haml b/app/views/projects/protected_tags/_tags_list.html.haml
index 728afd75b50..d432a5c9113 100644
--- a/app/views/projects/protected_tags/_tags_list.html.haml
+++ b/app/views/projects/protected_tags/_tags_list.html.haml
@@ -1,4 +1,4 @@
-.panel.panel-default.protected-tags-list.js-protected-tags-list
+.panel.panel-default.protected-tags-list
- if @protected_tags.empty?
.panel-heading
%h3.panel-title
@@ -13,6 +13,8 @@
%col{ width: "25%" }
%col{ width: "25%" }
%col{ width: "50%" }
+ - if can_admin_project
+ %col
%thead
%tr
%th Protected tag (#{@protected_tags.size})
diff --git a/app/views/projects/protected_tags/show.html.haml b/app/views/projects/protected_tags/show.html.haml
index 63743f28b3c..16fc02fe9f4 100644
--- a/app/views/projects/protected_tags/show.html.haml
+++ b/app/views/projects/protected_tags/show.html.haml
@@ -2,10 +2,10 @@
.row.prepend-top-default.append-bottom-default
.col-lg-3
- %h4.prepend-top-0
+ %h4.prepend-top-0.ref-name
= @protected_ref.name
- .col-lg-9
+ .col-lg-9.edit_protected_tag
%h5 Matching Tags
- if @matching_refs.present?
.table-responsive
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index be128e92fa7..5661af01302 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -1,26 +1,60 @@
- page_title "Container Registry"
-%hr
-
-%ul.content-list
- %li.light.prepend-top-default
+.row.prepend-top-default.append-bottom-default
+ .col-lg-3
+ %h4.prepend-top-0
+ = page_title
%p
- A 'container image' is a snapshot of a container.
- You can host your container images with GitLab.
- %br
- To start using container images hosted on GitLab you first need to login:
- %pre
- %code
+ With the Docker Container Registry integrated into GitLab, every project
+ can have its own space to store its Docker images.
+ %p.append-bottom-0
+ = succeed '.' do
+ Learn more about
+ = link_to 'Container Registry', help_page_path('user/project/container_registry'), target: '_blank'
+
+ .col-lg-9
+ .panel.panel-default
+ .panel-heading
+ %h4.panel-title
+ How to use the Container Registry
+ .panel-body
+ %p
+ First log in to GitLab&rsquo;s Container Registry using your GitLab username
+ and password. If you have
+ = link_to '2FA enabled', help_page_path('user/profile/account/two_factor_authentication'), target: '_blank'
+ you need to use a
+ = succeed ':' do
+ = link_to 'personal access token', help_page_path('user/profile/account/two_factor_authentication', anchor: 'personal-access-tokens'), target: '_blank'
+ %pre
docker login #{Gitlab.config.registry.host_port}
- %br
- Then you are free to create and upload a container image with build and push commands:
- %pre
- docker build -t #{escape_once(@project.container_registry_url)}/image .
%br
- docker push #{escape_once(@project.container_registry_url)}/image
+ %p
+ Once you log in, you&rsquo;re free to create and upload a container image
+ using the common
+ %code build
+ and
+ %code push
+ commands:
+ %pre
+ :plain
+ docker build -t #{escape_once(@project.container_registry_url)} .
+ docker push #{escape_once(@project.container_registry_url)}
- - if @images.blank?
- .nothing-here-block No container image repositories in Container Registry for this project.
+ %hr
+ %h5.prepend-top-default
+ Use different image names
+ %p.light
+ GitLab supports up to 3 levels of image names. The following
+ examples of images are valid for your project:
+ %pre
+ :plain
+ #{escape_once(@project.container_registry_url)}:tag
+ #{escape_once(@project.container_registry_url)}/optional-image-name:tag
+ #{escape_once(@project.container_registry_url)}/optional-name/optional-image-name:tag
- - else
- = render partial: 'image', collection: @images
+ - if @images.blank?
+ %p.settings-message.text-center.append-bottom-default
+ No container images stored for this project. Add one by following the
+ instructions above.
+ - else
+ = render partial: 'image', collection: @images
diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml
index deeadb609f6..674f87e8220 100644
--- a/app/views/projects/runners/_runner.html.haml
+++ b/app/views/projects/runners/_runner.html.haml
@@ -1,15 +1,18 @@
%li.runner{ id: dom_id(runner) }
%h4
= runner_status_icon(runner)
- %span.monospace
- - if @project_runners.include?(runner)
- = link_to runner.short_sha, runner_path(runner)
- - if runner.locked?
- = icon('lock', class: 'has-tooltip', title: 'Locked to current projects')
- %small
- = link_to edit_namespace_project_runner_path(@project.namespace, @project, runner) do
- %i.fa.fa-edit.btn
- - else
+
+ - if @project_runners.include?(runner)
+ = link_to runner.short_sha, runner_path(runner), class: 'commit-sha'
+
+ - if runner.locked?
+ = icon('lock', class: 'has-tooltip', title: 'Locked to current projects')
+
+ %small
+ = link_to edit_namespace_project_runner_path(@project.namespace, @project, runner) do
+ %i.fa.fa-edit.btn
+ - else
+ %span.commit-sha
= runner.short_sha
.pull-right
diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml
index 6b8e6bd4fee..f8835454140 100644
--- a/app/views/projects/runners/_specific_runners.html.haml
+++ b/app/views/projects/runners/_specific_runners.html.haml
@@ -9,7 +9,7 @@
(checkout the #{link_to 'GitLab Runner section', 'https://about.gitlab.com/gitlab-ci/#gitlab-runner', target: '_blank'} for information on how to install it).
%li
Specify the following URL during the Runner setup:
- %code= ci_root_url(only_path: false)
+ %code= root_url(only_path: false)
%li
Use the following registration token during setup:
%code= @project.runners_token
diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml
index f1a80f1d5e1..9167789a69d 100644
--- a/app/views/projects/services/_form.html.haml
+++ b/app/views/projects/services/_form.html.haml
@@ -1,3 +1,6 @@
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag('integrations')
+
.row.prepend-top-default.append-bottom-default
.col-lg-3
%h4.prepend-top-0
@@ -6,15 +9,17 @@
%p= @service.description
.col-lg-9
- = form_for(@service, as: :service, url: namespace_project_service_path(@project.namespace, @project, @service.to_param), method: :put, html: { class: 'form-horizontal' }) do |form|
+ = form_for(@service, as: :service, url: namespace_project_service_path(@project.namespace, @project, @service.to_param), method: :put, html: { class: 'gl-show-field-errors form-horizontal js-integration-settings-form', data: { 'can-test' => @service.can_test?, 'test-url' => test_namespace_project_service_path } }) do |form|
= render 'shared/service_settings', form: form, subject: @service
.footer-block.row-content-block
- = form.submit 'Save changes', class: 'btn btn-save'
+ %button.btn.btn-save{ type: 'submit' }
+ = icon('spinner spin', class: 'hidden js-btn-spinner')
+ %span.js-btn-label
+ Save changes
&nbsp;
- if @service.valid? && @service.activated?
- unless @service.can_test?
- disabled_class = 'disabled'
- disabled_title = @service.disabled_title
- = link_to 'Test settings', test_namespace_project_service_path(@project.namespace, @project, @service), class: "btn #{disabled_class}", title: disabled_title
- = link_to "Cancel", namespace_project_settings_integrations_path(@project.namespace, @project), class: "btn btn-cancel"
+ = link_to 'Cancel', namespace_project_settings_integrations_path(@project.namespace, @project), class: 'btn btn-cancel'
diff --git a/app/views/projects/settings/_head.html.haml b/app/views/projects/settings/_head.html.haml
index 8c7f9e0191e..00bd563999f 100644
--- a/app/views/projects/settings/_head.html.haml
+++ b/app/views/projects/settings/_head.html.haml
@@ -14,7 +14,7 @@
%span
Members
- if can_edit
- = nav_link(controller: [:integrations, :services, :hooks]) do
+ = nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do
= link_to project_settings_integrations_path(@project), title: 'Integrations' do
%span
Integrations
@@ -24,9 +24,9 @@
Repository
- if @project.feature_available?(:builds, current_user)
= nav_link(controller: :ci_cd) do
- = link_to namespace_project_settings_ci_cd_path(@project.namespace, @project), title: 'CI/CD Pipelines' do
+ = link_to namespace_project_settings_ci_cd_path(@project.namespace, @project), title: 'Pipelines' do
%span
- CI/CD Pipelines
+ Pipelines
- if Gitlab.config.pages.enabled
= nav_link(controller: :pages) do
= link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages' do
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index e2603096014..e8d2e91bd76 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -1,4 +1,4 @@
-- page_title "CI/CD Pipelines"
+- page_title "Pipelines"
= render "projects/settings/head"
= render 'projects/runners/index'
diff --git a/app/views/projects/settings/integrations/_project_hook.html.haml b/app/views/projects/settings/integrations/_project_hook.html.haml
index 8dc276a3bec..a6640592dba 100644
--- a/app/views/projects/settings/integrations/_project_hook.html.haml
+++ b/app/views/projects/settings/integrations/_project_hook.html.haml
@@ -3,7 +3,7 @@
.col-md-8.col-lg-7
%strong.light-header= hook.url
%div
- - %w(push_events tag_push_events issues_events confidential_issues_events note_events merge_requests_events build_events pipeline_events wiki_page_events).each do |trigger|
+ - %w(push_events tag_push_events issues_events confidential_issues_events note_events merge_requests_events job_events pipeline_events wiki_page_events).each do |trigger|
- if hook.send(trigger)
%span.label.label-gray.deploy-project-label= trigger.titleize
.col-md-4.col-lg-5.text-right-lg.prepend-top-5
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index 4e59033c4a3..40ea02abce9 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -1,10 +1,11 @@
- page_title "Repository"
+- @content_class = "limit-container-width" unless fluid_layout
= 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"
+= render @deploy_keys
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index d6c4195e2d0..7447197ed89 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -17,24 +17,24 @@
%ul.nav
%li
= link_to project_files_path(@project) do
- Files (#{storage_counter(@project.statistics.total_repository_size)})
+ #{_('Files')} (#{storage_counter(@project.statistics.total_repository_size)})
%li
= link_to namespace_project_commits_path(@project.namespace, @project, current_ref) do
- #{'Commit'.pluralize(@project.statistics.commit_count)} (#{number_with_delimiter(@project.statistics.commit_count)})
- %li
+ #{n_('Commit', 'Commits', @project.statistics.commit_count)} (#{number_with_delimiter(@project.statistics.commit_count)})
+ %l
= link_to namespace_project_branches_path(@project.namespace, @project) do
- #{'Branch'.pluralize(@repository.branch_count)} (#{number_with_delimiter(@repository.branch_count)})
+ #{n_('Branch', 'Branches', @repository.branch_count)} (#{number_with_delimiter(@repository.branch_count)})
%li
= link_to namespace_project_tags_path(@project.namespace, @project) do
- #{'Tag'.pluralize(@repository.tag_count)} (#{number_with_delimiter(@repository.tag_count)})
+ #{n_('Tag', 'Tags', @repository.tag_count)} (#{number_with_delimiter(@repository.tag_count)})
- if default_project_view != 'readme' && @repository.readme
%li
- = link_to 'Readme', readme_path(@project)
+ = link_to _('Readme'), readme_path(@project)
- if @repository.changelog
%li
- = link_to 'Changelog', changelog_path(@project)
+ = link_to _('Changelog'), changelog_path(@project)
- if @repository.license_blob
%li
@@ -42,48 +42,43 @@
- if @repository.contribution_guide
%li
- = link_to 'Contribution guide', contribution_guide_path(@project)
+ = link_to _('Contribution guide'), contribution_guide_path(@project)
- if @repository.gitlab_ci_yml
%li
- = link_to 'CI configuration', ci_configuration_path(@project)
+ = link_to _('CI configuration'), ci_configuration_path(@project)
- if current_user && can_push_branch?(@project, @project.default_branch)
- unless @repository.changelog
%li.missing
= link_to add_special_file_path(@project, file_name: 'CHANGELOG') do
- Add Changelog
+ #{ _('Add Changelog') }
- unless @repository.license_blob
%li.missing
= link_to add_special_file_path(@project, file_name: 'LICENSE') do
- Add License
+ #{ _('Add License') }
- unless @repository.contribution_guide
%li.missing
= link_to add_special_file_path(@project, file_name: 'CONTRIBUTING.md', commit_message: 'Add contribution guide') do
- Add Contribution guide
+ #{ _('Add Contribution guide') }
- unless @repository.gitlab_ci_yml
%li.missing
= link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml') do
- Set up CI
+ #{ _('Set up CI') }
- if koding_enabled? && @repository.koding_yml.blank?
%li.missing
- = link_to 'Set up Koding', add_koding_stack_path(@project)
+ = link_to _('Set up Koding'), add_koding_stack_path(@project)
- if @repository.gitlab_ci_yml.blank? && @project.deployment_service.present?
%li.missing
= link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml', commit_message: 'Set up auto deploy', branch_name: 'auto-deploy', context: 'autodeploy') do
- Set up auto deploy
-
- - if @repository.commit
- %div{ class: container_class }
- .project-last-commit
- = render 'projects/last_commit', commit: @repository.commit, ref: current_ref, project: @project
+ #{ _('Set up auto deploy') }
%div{ class: container_class }
- if @project.archived?
.text-warning.center.prepend-top-20
%p
= icon("exclamation-triangle fw")
- Archived project! Repository is read-only
+ #{ _('Archived project! Repository is read-only') }
- view_path = default_project_view
diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml
index aab1c043e66..847f3c2f348 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 "shared/notes/notes_with_form"
+ #notes= render "shared/notes/notes_with_form", :autocomplete => true
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index 4c4f3655b97..44cb734d7b9 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -2,10 +2,9 @@
- release = @releases.find { |release| release.tag == tag.name }
%li.flex-row
.row-main-content.str-truncated
- = link_to namespace_project_tag_path(@project.namespace, @project, tag.name) do
- %span.item-title
- = icon('tag')
- = tag.name
+ = link_to namespace_project_tag_path(@project.namespace, @project, tag.name), class: 'item-title ref-name' do
+ = icon('tag')
+ = tag.name
- if protected_tag?(@project, tag)
%span.label.label-success
diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml
index ce0eaff3060..52af295bddd 100644
--- a/app/views/projects/tags/new.html.haml
+++ b/app/views/projects/tags/new.html.haml
@@ -27,14 +27,14 @@
.form-group
= label_tag :message, nil, class: 'control-label'
.col-sm-10
- = text_area_tag :message, nil, required: false, tabindex: 3, class: 'form-control', rows: 5
+ = text_area_tag :message, @message, required: false, tabindex: 3, class: 'form-control', rows: 5
.help-block Optionally, add a message to the tag.
%hr
.form-group
= label_tag :release_description, 'Release notes', class: 'control-label'
.col-sm-10
= 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/zen', attr: :release_description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here...", current_text: @release_description
= 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
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index e996ae3e4fc..2b81ce4b9fa 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -6,7 +6,9 @@
.top-area.multi-line
.nav-text
.title
- %span.item-title= @tag.name
+ %span.item-title.ref-name
+ = icon('tag')
+ = @tag.name
- if protected_tag?(@project, @tag)
%span.label.label-success
protected
diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml
index 01599060844..de57cd4ba00 100644
--- a/app/views/projects/tree/_readme.html.haml
+++ b/app/views/projects/tree/_readme.html.haml
@@ -1,8 +1,9 @@
-%article.file-holder.readme-holder
- .js-file-title.file-title
- = blob_icon readme.mode, readme.name
- = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, @path, readme.name)) do
- %strong
- = readme.name
- .file-content.wiki
- = markup(readme.name, readme.data)
+- if readme.rich_viewer
+ %article.file-holder.readme-holder
+ .js-file-title.file-title
+ = blob_icon readme.mode, readme.name
+ = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, readme.path)) do
+ %strong
+ = readme.name
+
+ = render 'projects/blob/viewer', viewer: readme.rich_viewer, viewer_url: namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, readme.path), viewer: :rich, format: :json)
diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml
index 2497a2d91b1..7854e1305db 100644
--- a/app/views/projects/tree/_tree_content.html.haml
+++ b/app/views/projects/tree/_tree_content.html.haml
@@ -3,20 +3,10 @@
%table.table#tree-slider{ class: "table_#{@hex_path} tree-table" }
%thead
%tr
- %th Name
+ %th= s_('ProjectFileTree|Name')
%th.hidden-xs
- .pull-left Last commit
- .last-commit.hidden-sm.pull-left
- %i.fa.fa-angle-right
- %small.light
- = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @commit), class: "monospace"
- = clipboard_button(text: @commit.id, title: "Copy commit SHA to clipboard")
- = time_ago_with_tooltip(@commit.committed_date)
- \-
- = @commit.full_title
- %small.commit-history-link-spacer &#124;
- = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), class: 'commit-history-link'
- %th.text-right Last Update
+ .pull-left= _('Last commit')
+ %th.text-right= _('Last Update')
- if @path.present?
%tr.tree-item
%td.tree-item-file-name
@@ -30,7 +20,7 @@
= render "projects/tree/readme", readme: tree.readme
- if can_edit_tree?
- = render 'projects/blob/upload', title: 'Upload New File', placeholder: 'Upload new file', button_title: 'Upload file', form_path: namespace_project_create_blob_path(@project.namespace, @project, @id), method: :post
+ = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: namespace_project_create_blob_path(@project.namespace, @project, @id), method: :post
= render 'projects/blob/new_dir'
:javascript
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index 396d1ecd77b..abde2a48587 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -1,5 +1,8 @@
.tree-controls
= render 'projects/find_file_link'
+
+ = link_to s_('Commits|History'), namespace_project_commits_path(@project.namespace, @project, @id), class: 'btn btn-grouped'
+
= render 'projects/buttons/download', project: @project, ref: @ref
.tree-ref-holder
@@ -16,7 +19,7 @@
- if current_user
%li
- if !on_top_of_branch?
- %span.btn.add-to-tree.disabled.has-tooltip{ title: "You can only add files when you are on a branch", data: { container: 'body' } }
+ %span.btn.add-to-tree.disabled.has-tooltip{ title: _("You can only add files when you are on a branch"), data: { container: 'body' } }
= icon('plus')
- else
%span.dropdown
@@ -27,15 +30,15 @@
%li
= link_to namespace_project_new_blob_path(@project.namespace, @project, @id) do
= icon('pencil fw')
- New file
+ #{ _('New file') }
%li
= link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do
= icon('file fw')
- Upload file
+ #{ _('Upload file') }
%li
= link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do
= icon('folder fw')
- New directory
+ #{ _('New directory') }
- elsif can?(current_user, :fork_project, @project)
%li
- continue_params = { to: namespace_project_new_blob_path(@project.namespace, @project, @id),
@@ -45,7 +48,7 @@
continue: continue_params)
= link_to fork_path, method: :post do
= icon('pencil fw')
- New file
+ #{ _('New file') }
%li
- continue_params = { to: request.fullpath,
notice: edit_in_new_fork_notice + " Try to upload a file again.",
@@ -54,7 +57,7 @@
continue: continue_params)
= link_to fork_path, method: :post do
= icon('file fw')
- Upload file
+ #{ _('Upload file') }
%li
- continue_params = { to: request.fullpath,
notice: edit_in_new_fork_notice + " Try to create a new directory again.",
@@ -63,14 +66,14 @@
continue: continue_params)
= link_to fork_path, method: :post do
= icon('folder fw')
- New directory
+ #{ _('New directory') }
%li.divider
%li
= link_to new_namespace_project_branch_path(@project.namespace, @project) do
= icon('code-fork fw')
- New branch
+ #{ _('New branch') }
%li
= link_to new_namespace_project_tag_path(@project.namespace, @project) do
= icon('tags fw')
- New tag
+ #{ _('New tag') }
diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml
index 910d765aed0..96a08f9f8be 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -1,10 +1,11 @@
- @no_container = true
-- page_title @path.presence || "Files", @ref
+- page_title @path.presence || _("Files"), @ref
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, namespace_project_commits_url(@project.namespace, @project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits")
= render "projects/commits/head"
+
= render 'projects/last_push'
%div{ class: container_class }
- = render 'projects/files'
+ = render 'projects/files', commit: @last_commit, project: @project, ref: @ref
diff --git a/app/views/projects/variables/_content.html.haml b/app/views/projects/variables/_content.html.haml
index 06477aba103..98f618ca3b8 100644
--- a/app/views/projects/variables/_content.html.haml
+++ b/app/views/projects/variables/_content.html.haml
@@ -1,7 +1,8 @@
%h4.prepend-top-0
- Secret Variables
+ Secret variables
+ = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank'
%p
- These variables will be set to environment by the runner.
+ These variables will be set to environment by the runner, and could be protected by exposing only to protected branches or tags.
%p
So you can use them for passwords, secret keys or whatever you want.
%p
diff --git a/app/views/projects/variables/_form.html.haml b/app/views/projects/variables/_form.html.haml
index 1ae86d258af..0a70a301cb4 100644
--- a/app/views/projects/variables/_form.html.haml
+++ b/app/views/projects/variables/_form.html.haml
@@ -7,4 +7,13 @@
.form-group
= f.label :value, "Value", class: "label-light"
= f.text_area :value, class: "form-control", placeholder: "PROJECT_VARIABLE"
+ .form-group
+ .checkbox
+ = f.label :protected do
+ = f.check_box :protected
+ %strong Protected
+ .help-block
+ This variable will be passed only to pipelines running on protected branches and tags
+ = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'protected-secret-variables'), target: '_blank'
+
= f.submit btn_text, class: "btn btn-save"
diff --git a/app/views/projects/variables/_table.html.haml b/app/views/projects/variables/_table.html.haml
index 0ce597dcf21..59cd3c4b592 100644
--- a/app/views/projects/variables/_table.html.haml
+++ b/app/views/projects/variables/_table.html.haml
@@ -3,10 +3,12 @@
%colgroup
%col
%col
+ %col
%col{ width: 100 }
%thead
%th Key
%th Value
+ %th Protected
%th
%tbody
- @project.variables.order_key_asc.each do |variable|
@@ -14,6 +16,7 @@
%tr
%td.variable-key= variable.key
%td.variable-value{ "data-value" => variable.value }******
+ %td.variable-protected= Gitlab::Utils.boolean_to_yes_no(variable.protected)
%td.variable-menu
= link_to namespace_project_variable_path(@project.namespace, @project, variable), class: "btn btn-transparent btn-variable-edit" do
%span.sr-only
diff --git a/app/views/projects/wikis/_sidebar.html.haml b/app/views/projects/wikis/_sidebar.html.haml
index 713b758727e..c2f9e65015d 100644
--- a/app/views/projects/wikis/_sidebar.html.haml
+++ b/app/views/projects/wikis/_sidebar.html.haml
@@ -1,4 +1,4 @@
-%aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "102", "spy" => "affix" } }
+%aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "50", "spy" => "affix" } }
.block.wiki-sidebar-header.append-bottom-default
%a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-wiki-toggle{ href: "#" }
= icon('angle-double-right')
diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml
index 059a0d1ac78..314d8e9cb25 100644
--- a/app/views/search/_category.html.haml
+++ b/app/views/search/_category.html.haml
@@ -3,41 +3,48 @@
.fade-right= icon('angle-right')
%ul.nav-links.search-filter.scrolling-tabs
- if @project
- %li{ class: active_when(@scope == 'blobs') }
- = link_to search_filter_path(scope: 'blobs') do
- Code
- %span.badge
- = @search_results.blobs_count
- %li{ class: active_when(@scope == 'issues') }
- = link_to search_filter_path(scope: 'issues') do
- Issues
- %span.badge
- = @search_results.issues_count
- %li{ class: active_when(@scope == 'merge_requests') }
- = link_to search_filter_path(scope: 'merge_requests') do
- Merge requests
- %span.badge
- = @search_results.merge_requests_count
- %li{ class: active_when(@scope == 'milestones') }
- = link_to search_filter_path(scope: 'milestones') do
- Milestones
- %span.badge
- = @search_results.milestones_count
- %li{ class: active_when(@scope == 'notes') }
- = link_to search_filter_path(scope: 'notes') do
- Comments
- %span.badge
- = @search_results.notes_count
- %li{ class: active_when(@scope == 'wiki_blobs') }
- = link_to search_filter_path(scope: 'wiki_blobs') do
- Wiki
- %span.badge
- = @search_results.wiki_blobs_count
- %li{ class: active_when(@scope == 'commits') }
- = link_to search_filter_path(scope: 'commits') do
- Commits
- %span.badge
- = @search_results.commits_count
+ - if project_search_tabs?(:blobs)
+ %li{ class: active_when(@scope == 'blobs') }
+ = link_to search_filter_path(scope: 'blobs') do
+ Code
+ %span.badge
+ = @search_results.blobs_count
+ - if project_search_tabs?(:issues)
+ %li{ class: active_when(@scope == 'issues') }
+ = link_to search_filter_path(scope: 'issues') do
+ Issues
+ %span.badge
+ = @search_results.issues_count
+ - if project_search_tabs?(:merge_requests)
+ %li{ class: active_when(@scope == 'merge_requests') }
+ = link_to search_filter_path(scope: 'merge_requests') do
+ Merge requests
+ %span.badge
+ = @search_results.merge_requests_count
+ - if project_search_tabs?(:milestones)
+ %li{ class: active_when(@scope == 'milestones') }
+ = link_to search_filter_path(scope: 'milestones') do
+ Milestones
+ %span.badge
+ = @search_results.milestones_count
+ - if project_search_tabs?(:notes)
+ %li{ class: active_when(@scope == 'notes') }
+ = link_to search_filter_path(scope: 'notes') do
+ Comments
+ %span.badge
+ = @search_results.notes_count
+ - if project_search_tabs?(:wiki)
+ %li{ class: active_when(@scope == 'wiki_blobs') }
+ = link_to search_filter_path(scope: 'wiki_blobs') do
+ Wiki
+ %span.badge
+ = @search_results.wiki_blobs_count
+ - if project_search_tabs?(:commits)
+ %li{ class: active_when(@scope == 'commits') }
+ = link_to search_filter_path(scope: 'commits') do
+ Commits
+ %span.badge
+ = @search_results.commits_count
- elsif @show_snippets
%li{ class: active_when(@scope == 'snippet_blobs') }
diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml
index 938be20c7cf..e43796e9654 100644
--- a/app/views/search/_filter.html.haml
+++ b/app/views/search/_filter.html.haml
@@ -3,7 +3,7 @@
- if params[:project_id].present?
= hidden_field_tag :project_id, params[:project_id]
.dropdown
- %button.dropdown-menu-toggle.js-search-group-dropdown{ type: "button", data: { toggle: "dropdown", default_label: "Group:" } }
+ %button.dropdown-menu-toggle.js-search-group-dropdown{ type: "button", data: { toggle: "dropdown", default_label: "Group:", group_id: params[:group_id] } }
%span.dropdown-toggle-text
Group:
- if @group.present?
diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml
index 9ce6a1aeef5..de52fd00157 100644
--- a/app/views/sent_notifications/unsubscribe.html.haml
+++ b/app/views/sent_notifications/unsubscribe.html.haml
@@ -1,16 +1,14 @@
- noteable = @sent_notification.noteable
-- noteable_type = @sent_notification.noteable_type.humanize(capitalize: false)
+- noteable_type = @sent_notification.noteable_type.titleize.downcase
- noteable_text = %(#{noteable.title} (#{noteable.to_reference}))
-
-- page_title "Unsubscribe", noteable_text, @sent_notification.noteable_type.humanize.pluralize, @sent_notification.project.name_with_namespace
-
+- page_title "Unsubscribe", noteable_text, noteable_type.pluralize, @sent_notification.project.name_with_namespace
%h3.page-title
- Unsubscribe from #{noteable_type} #{noteable_text}
+ Unsubscribe from #{noteable_type}
%p
= succeed '?' do
- Are you sure you want to unsubscribe from #{noteable_type}
+ Are you sure you want to unsubscribe from the #{noteable_type}:
= link_to noteable_text, url_for([@sent_notification.project.namespace.becomes(Namespace), @sent_notification.project, noteable])
%p
diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml
index 34a4d7398bc..0aad4d0714f 100644
--- a/app/views/shared/_clone_panel.html.haml
+++ b/app/views/shared/_clone_panel.html.haml
@@ -17,9 +17,9 @@
%li
= http_clone_button(project)
- = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true
+ = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' }
.input-group-btn
- = clipboard_button(target: '#project_clone', title: "Copy URL to clipboard")
+ = clipboard_button(target: '#project_clone', title: _("Copy URL to clipboard"))
:javascript
$('ul.clone-options-dropdown a').on('click',function(e){
diff --git a/app/views/shared/_field.html.haml b/app/views/shared/_field.html.haml
index 8d6e16f74c3..795447a9ca6 100644
--- a/app/views/shared/_field.html.haml
+++ b/app/views/shared/_field.html.haml
@@ -3,25 +3,26 @@
- value = @service.send(name)
- type = field[:type]
- placeholder = field[:placeholder]
+- required = field[:required]
- choices = field[:choices]
- default_choice = field[:default_choice]
- help = field[:help]
.form-group
- if type == "password" && value.present?
- = form.label name, "Change #{title}", class: "control-label"
+ = form.label name, "Enter new #{title.downcase}", class: "control-label"
- else
= form.label name, title, class: "control-label"
.col-sm-10
- if type == 'text'
- = form.text_field name, class: "form-control", placeholder: placeholder
+ = form.text_field name, class: "form-control", placeholder: placeholder, required: required
- elsif type == 'textarea'
- = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder
+ = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder, required: required
- elsif type == 'checkbox'
= form.check_box name
- elsif type == 'select'
= form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control" }
- elsif type == 'password'
- = form.password_field name, autocomplete: "new-password", class: 'form-control'
+ = form.password_field name, autocomplete: "new-password", class: "form-control", required: value.blank? && :required
- if help
%span.help-block= help
diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml
index 90ae3f06a98..8d5b5129454 100644
--- a/app/views/shared/_group_form.html.haml
+++ b/app/views/shared/_group_form.html.haml
@@ -15,7 +15,7 @@
%strong= parent.full_path + '/'
= f.text_field :path, placeholder: 'open-source', class: 'form-control',
autofocus: local_assigns[:autofocus] || false, required: true,
- pattern: Gitlab::Regex::NAMESPACE_REGEX_STR_JS,
+ pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS,
title: 'Please choose a group path with no special characters.',
"data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
- if parent
diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml
index 07970ad9cba..aa93572bf94 100644
--- a/app/views/shared/_mini_pipeline_graph.html.haml
+++ b/app/views/shared/_mini_pipeline_graph.html.haml
@@ -1,5 +1,5 @@
.stage-cell
- - pipeline.stages.each do |stage|
+ - pipeline.legacy_stages.each do |stage|
- if stage.status
- detailed_status = stage.detailed_status(current_user)
- icon_status = "#{detailed_status.icon}_borderless"
diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml
index fbbf6f358c5..9ed844cf5e7 100644
--- a/app/views/shared/_new_project_item_select.html.haml
+++ b/app/views/shared/_new_project_item_select.html.haml
@@ -1,6 +1,6 @@
- if @projects.any?
.project-item-select-holder
- = project_select_tag :project_path, class: "project-item-select", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at' }
+ = project_select_tag :project_path, class: "project-item-select", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at' }, with_feature_enabled: local_assigns[:with_feature_enabled]
%a.btn.btn-new.new-project-item-select-button
= local_assigns[:label]
= icon('caret-down')
diff --git a/app/views/shared/_no_password.html.haml b/app/views/shared/_no_password.html.haml
index ed6fc76c61e..b561e6dc248 100644
--- a/app/views/shared/_no_password.html.haml
+++ b/app/views/shared/_no_password.html.haml
@@ -1,8 +1,10 @@
- if cookies[:hide_no_password_message].blank? && !current_user.hide_no_password && current_user.require_password?
.no-password-message.alert.alert-warning
- You won't be able to pull or push project code via #{gitlab_config.protocol.upcase} until you #{link_to 'set a password', edit_profile_password_path} on your account
+ - set_password_link = link_to s_('SetPasswordToCloneLink|set a password'), edit_profile_password_path
+ - translation_params = { protocol: gitlab_config.protocol.upcase, set_password_link: set_password_link }
+ - set_password_message = _("You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account") % translation_params
.alert-link-group
- = link_to "Don't show again", profile_path(user: {hide_no_password: true}), method: :put
+ = link_to _("Don't show again"), profile_path(user: {hide_no_password: true}), method: :put
|
- = link_to 'Remind later', '#', class: 'hide-no-password-message'
+ = link_to _('Remind later'), '#', class: 'hide-no-password-message'
diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml
index d663fa13d10..e7815e28017 100644
--- a/app/views/shared/_no_ssh.html.haml
+++ b/app/views/shared/_no_ssh.html.haml
@@ -1,8 +1,9 @@
- if cookies[:hide_no_ssh_message].blank? && !current_user.hide_no_ssh_key && current_user.require_ssh_key?
.no-ssh-key-message.alert.alert-warning
- You won't be able to pull or push project code via SSH until you #{link_to 'add an SSH key', profile_keys_path, class: 'alert-link'} to your profile
-
+ - add_ssh_key_link = link_to s_('MissingSSHKeyWarningLink|add an SSH key'), profile_keys_path, class: 'alert-link'
+ - ssh_message = _("You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile") % { add_ssh_key_link: add_ssh_key_link }
+ #{ ssh_message.html_safe }
.alert-link-group
- = link_to "Don't show again", profile_path(user: {hide_no_ssh_key: true}), method: :put, class: 'alert-link'
+ = link_to _("Don't show again"), profile_path(user: {hide_no_ssh_key: true}), method: :put, class: 'alert-link'
|
- = link_to 'Remind later', '#', class: 'hide-no-ssh-message alert-link'
+ = link_to _('Remind later'), '#', class: 'hide-no-ssh-message alert-link'
diff --git a/app/views/shared/_ref_dropdown.html.haml b/app/views/shared/_ref_dropdown.html.haml
index 96f68c80c48..8b2a3bee407 100644
--- a/app/views/shared/_ref_dropdown.html.haml
+++ b/app/views/shared/_ref_dropdown.html.haml
@@ -1,6 +1,6 @@
- dropdown_class = local_assigns.fetch(:dropdown_class, '')
-.dropdown-menu.dropdown-menu-selectable{ class: dropdown_class }
+.dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: dropdown_class }
= dropdown_title "Select Git revision"
= dropdown_filter "Filter by Git revision"
= dropdown_content
diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml
index 9a8252ab087..d52bb6b4dd7 100644
--- a/app/views/shared/_ref_switcher.html.haml
+++ b/app/views/shared/_ref_switcher.html.haml
@@ -7,8 +7,8 @@
= hidden_field_tag key, value, id: nil
.dropdown
= dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_namespace_project_path(@project.namespace, @project), field_name: 'ref', submit_form_on_click: true }, { toggle_class: "js-project-refs-dropdown" }
- .dropdown-menu.dropdown-menu-selectable{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) }
- = dropdown_title "Switch branch/tag"
- = dropdown_filter "Search branches and tags"
+ .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) }
+ = dropdown_title _("Switch branch/tag")
+ = dropdown_filter _("Search branches and tags")
= dropdown_content
= dropdown_loading
diff --git a/app/views/shared/_user_callout.html.haml b/app/views/shared/_user_callout.html.haml
index 8308baa7829..17ffcba69d8 100644
--- a/app/views/shared/_user_callout.html.haml
+++ b/app/views/shared/_user_callout.html.haml
@@ -1,4 +1,4 @@
-.user-callout
+.user-callout{ data: { uid: 'user_callout_dismissed' } }
.bordered-box.landing.content-block
%button.btn.btn-default.close.js-close-callout{ type: 'button',
'aria-label' => 'Dismiss customize experience box' }
diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml
new file mode 100644
index 00000000000..e6075c3ae3a
--- /dev/null
+++ b/app/views/shared/deploy_keys/_form.html.haml
@@ -0,0 +1,30 @@
+- form = local_assigns.fetch(:form)
+- deploy_key = local_assigns.fetch(:deploy_key)
+
+= form_errors(deploy_key)
+
+.form-group
+ = form.label :title, class: 'control-label'
+ .col-sm-10= form.text_field :title, class: 'form-control'
+
+.form-group
+ - if deploy_key.new_record?
+ = form.label :key, class: 'control-label'
+ .col-sm-10
+ %p.light
+ Paste a machine public key here. Read more about how to generate it
+ = link_to 'here', help_page_path('ssh/README')
+ = form.text_area :key, class: 'form-control thin_area', rows: 5
+ - else
+ = form.label :fingerprint, class: 'control-label'
+ .col-sm-10
+ = form.text_field :fingerprint, class: 'form-control', readonly: 'readonly'
+
+.form-group
+ .control-label
+ .col-sm-10
+ = form.label :can_push do
+ = form.check_box :can_push
+ %strong Write access allowed
+ %p.light.append-bottom-0
+ Allow this key to push to repository as well? (Default only allows pull access.)
diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml
index c229d18903f..046b127f73c 100644
--- a/app/views/shared/empty_states/_issues.html.haml
+++ b/app/views/shared/empty_states/_issues.html.haml
@@ -3,10 +3,10 @@
- has_button = button_path || project_select_button
.row.empty-state
- .pull-right.col-xs-12{ class: "#{'col-sm-6' if has_button}" }
+ .col-xs-12
.svg-content
= render 'shared/empty_states/icons/issues.svg'
- .col-xs-12{ class: "#{'col-sm-6' if has_button}" }
+ .col-xs-12.text-center
.text-content
- if has_button && current_user
%h4
@@ -20,4 +20,3 @@
- else
.text-center
%h4 There are no issues to show.
- = link_to 'New issue', button_path, class: 'btn btn-new', title: 'New issue', id: 'new_issue_link'
diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml
index 00fb77bdb3b..5e2f4cf109d 100644
--- a/app/views/shared/empty_states/_labels.html.haml
+++ b/app/views/shared/empty_states/_labels.html.haml
@@ -1,8 +1,8 @@
.row.empty-state.labels
- .pull-right.col-xs-12.col-sm-6
+ .col-xs-12
.svg-content
= render 'shared/empty_states/icons/labels.svg'
- .col-xs-12.col-sm-6
+ .col-xs-12.text-center
.text-content
%h4 Labels can be applied to issues and merge requests to categorize them.
%p You can also star a label to make it a priority label.
diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml
index 7f2f99f3406..3e64f403b8b 100644
--- a/app/views/shared/empty_states/_merge_requests.html.haml
+++ b/app/views/shared/empty_states/_merge_requests.html.haml
@@ -3,10 +3,10 @@
- has_button = button_path || project_select_button
.row.empty-state.merge-requests
- .col-xs-12{ class: "#{'col-sm-6 pull-right' if has_button}" }
+ .col-xs-12
.svg-content
= render 'shared/empty_states/icons/merge_requests.svg'
- .col-xs-12{ class: "#{'col-sm-6' if has_button}" }
+ .col-xs-12.text-center
.text-content
- if has_button
%h4
diff --git a/app/views/shared/empty_states/icons/_pipelines_empty.svg b/app/views/shared/empty_states/icons/_pipelines_empty.svg
index 8119d5bebe0..7c672538097 100644
--- a/app/views/shared/empty_states/icons/_pipelines_empty.svg
+++ b/app/views/shared/empty_states/icons/_pipelines_empty.svg
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 150"><g fill="none" fill-rule="evenodd" transform="translate(0-3)"><g transform="translate(0 105)"><g fill="#e5e5e5"><rect width="78" height="4" x="34" y="21" opacity=".5" rx="2"/><path d="m152 23c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2"/></g><g transform="translate(0 4)"><path fill="#98d7b2" fill-rule="nonzero" d="m19 38c-10.493 0-19-8.507-19-19 0-10.493 8.507-19 19-19 10.493 0 19 8.507 19 19 0 10.493-8.507 19-19 19m0-4c8.284 0 15-6.716 15-15 0-8.284-6.716-15-15-15-8.284 0-15 6.716-15 15 0 8.284 6.716 15 15 15"/><path fill="#31af64" d="m17.07 21.02l-2.829-2.829c-.786-.786-2.047-.781-2.828 0-.786.786-.781 2.047 0 2.828l4.243 4.243c.392.392.902.587 1.412.588.512.002 1.021-.193 1.41-.582l7.79-7.79c.777-.777.775-2.042-.006-2.823-.786-.786-2.045-.784-2.823-.006l-6.37 6.37"/></g><g fill="#e52c5a" transform="translate(102)"><path fill-rule="nonzero" d="m24 47.5c-12.979 0-23.5-10.521-23.5-23.5 0-12.979 10.521-23.5 23.5-23.5 12.979 0 23.5 10.521 23.5 23.5 0 12.979-10.521 23.5-23.5 23.5m0-5c10.217 0 18.5-8.283 18.5-18.5 0-10.217-8.283-18.5-18.5-18.5-10.217 0-18.5 8.283-18.5 18.5 0 10.217 8.283 18.5 18.5 18.5"/><path d="m28.24 24l2.833-2.833c1.167-1.167 1.167-3.067-.004-4.239-1.169-1.169-3.069-1.173-4.239-.004l-2.833 2.833-2.833-2.833c-1.167-1.167-3.067-1.167-4.239.004-1.169 1.169-1.173 3.069-.004 4.239l2.833 2.833-2.833 2.833c-1.167 1.167-1.167 3.067.004 4.239 1.169 1.169 3.069 1.173 4.239.004l2.833-2.833 2.833 2.833c1.167 1.167 3.067 1.167 4.239-.004 1.169-1.169 1.173-3.069.004-4.239l-2.833-2.833"/></g><path fill="#e5e5e5" fill-rule="nonzero" d="m236 37c-7.732 0-14-6.268-14-14 0-7.732 6.268-14 14-14 7.732 0 14 6.268 14 14 0 7.732-6.268 14-14 14m0-4c5.523 0 10-4.477 10-10 0-5.523-4.477-10-10-10-5.523 0-10 4.477-10 10 0 5.523 4.477 10 10 10"/></g><g transform="translate(69 3)"><path fill="#e5e5e5" fill-rule="nonzero" d="m4 11.99v60.02c0 4.413 3.583 7.99 8 7.99h89.991c4.419 0 8-3.579 8-7.99v-60.02c0-4.413-3.583-7.99-8-7.99h-89.991c-4.419 0-8 3.579-8 7.99m-4 0c0-6.622 5.378-11.99 12-11.99h89.991c6.629 0 12 5.367 12 11.99v60.02c0 6.622-5.378 11.99-12 11.99h-89.991c-6.629 0-12-5.367-12-11.99v-60.02m52.874 80.3l-13.253-15.292h34.76l-13.253 15.292c-2.237 2.582-6.01 2.585-8.253 0m3.02-2.62c.644.743 1.564.743 2.207 0l7.516-8.673h-17.24l7.516 8.673"/><rect width="18" height="6" x="15" y="23" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="39" y="39" fill="#e52c5a" rx="3"/><rect width="18" height="6" x="33" y="55" fill="#e5e5e5" rx="3"/><rect width="12" height="6" x="39" y="23" fill="#fde5d8" rx="3"/><rect width="12" height="6" x="57" y="55" fill="#e52c5a" rx="3"/><rect width="12" height="6" x="15" y="55" fill="#b5a7dd" rx="3"/><rect width="18" height="6" x="81" y="23" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="15" y="39" fill="#fde5d8" rx="3"/><rect width="6" height="6" x="57" y="23" fill="#e52c5a" rx="3"/><g fill="#fde5d8"><rect width="6" height="6" x="69" y="23" rx="3"/><rect width="6" height="6" x="75" y="39" rx="3"/></g><rect width="6" height="6" x="63" y="39" fill="#e52c5a" rx="3"/></g><g transform="matrix(.70711-.70711.70711.70711 84.34 52.5)"><path fill="#6b4fbb" fill-rule="nonzero" d="m28.02 67.48c-15.927-2.825-28.02-16.738-28.02-33.476 0-18.778 15.222-34 34-34 18.778 0 34 15.222 34 34 0 16.738-12.1 30.652-28.02 33.476.015.173.023.347.023.524v21.999c0 3.314-2.693 6-6 6-3.314 0-6-2.682-6-6v-21.999c0-.177.008-.351.023-.524m5.977-7.476c14.359 0 26-11.641 26-26 0-14.359-11.641-26-26-26-14.359 0-26 11.641-26 26 0 14.359 11.641 26 26 26"/><path fill="#fff" fill-opacity=".3" stroke="#6b4fbb" stroke-width="8" d="m31 71c16.569 0 30-13.431 30-30 0-16.569-13.431-30-30-30" transform="matrix(.86603.5-.5.86603 26.663-17.507)"/></g></g></svg> \ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 150"><g fill="none" fill-rule="evenodd"><g transform="translate(0 102)"><g fill="#e5e5e5"><rect width="74" height="4" x="34" y="21" opacity=".5" rx="2"/><path d="m152 23c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2"/></g><g fill="#31af64" transform="translate(0 4)"><path fill-rule="nonzero" d="m19 38c-10.493 0-19-8.507-19-19 0-10.493 8.507-19 19-19 10.493 0 19 8.507 19 19 0 10.493-8.507 19-19 19m0-4c8.284 0 15-6.716 15-15 0-8.284-6.716-15-15-15-8.284 0-15 6.716-15 15 0 8.284 6.716 15 15 15"/><path d="m17.07 21.02l-2.829-2.829c-.786-.786-2.047-.781-2.828 0-.786.786-.781 2.047 0 2.828l4.243 4.243c.392.392.902.587 1.412.588.512.002 1.021-.193 1.41-.582l7.79-7.79c.777-.777.775-2.042-.006-2.823-.786-.786-2.045-.784-2.823-.006l-6.37 6.37"/></g><g fill="#e52c5a" transform="translate(102)"><path fill-rule="nonzero" d="m24 47.5c-12.979 0-23.5-10.521-23.5-23.5 0-12.979 10.521-23.5 23.5-23.5 12.979 0 23.5 10.521 23.5 23.5 0 12.979-10.521 23.5-23.5 23.5m0-5c10.217 0 18.5-8.283 18.5-18.5 0-10.217-8.283-18.5-18.5-18.5-10.217 0-18.5 8.283-18.5 18.5 0 10.217 8.283 18.5 18.5 18.5"/><path d="m28.24 24l2.833-2.833c1.167-1.167 1.167-3.067-.004-4.239-1.169-1.169-3.069-1.173-4.239-.004l-2.833 2.833-2.833-2.833c-1.167-1.167-3.067-1.167-4.239.004-1.169 1.169-1.173 3.069-.004 4.239l2.833 2.833-2.833 2.833c-1.167 1.167-1.167 3.067.004 4.239 1.169 1.169 3.069 1.173 4.239.004l2.833-2.833 2.833 2.833c1.167 1.167 3.067 1.167 4.239-.004 1.169-1.169 1.173-3.069.004-4.239l-2.833-2.833"/></g><path fill="#e5e5e5" fill-rule="nonzero" d="m236 37c-7.732 0-14-6.268-14-14 0-7.732 6.268-14 14-14 7.732 0 14 6.268 14 14 0 7.732-6.268 14-14 14m0-4c5.523 0 10-4.477 10-10 0-5.523-4.477-10-10-10-5.523 0-10 4.477-10 10 0 5.523 4.477 10 10 10"/></g><g transform="translate(73 4)"><path stroke="#e5e5e5" stroke-width="4" d="m64.82 76h33.18c4.419 0 8-3.579 8-7.99v-60.02c0-4.413-3.583-7.99-8-7.99h-89.991c-4.419 0-8 3.579-8 7.99v60.02c0 4.413 3.583 7.99 8 7.99h31.935l9.263 9.855c1.725 1.835 4.631 1.833 6.354 0l9.263-9.855"/><rect width="18" height="6" x="11" y="19" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="35" y="35" fill="#e52c5a" rx="3"/><rect width="18" height="6" x="29" y="51" fill="#e5e5e5" rx="3"/><rect width="12" height="6" x="35" y="19" fill="#fde5d8" rx="3"/><rect width="12" height="6" x="53" y="51" fill="#e52c5a" rx="3"/><rect width="12" height="6" x="11" y="51" fill="#b5a7dd" rx="3"/><rect width="18" height="6" x="77" y="19" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="11" y="35" fill="#fde5d8" rx="3"/><rect width="6" height="6" x="53" y="19" fill="#e52c5a" rx="3"/><g fill="#fde5d8"><rect width="6" height="6" x="65" y="19" rx="3"/><rect width="6" height="6" x="71" y="35" rx="3"/></g><rect width="6" height="6" x="59" y="35" fill="#e52c5a" rx="3"/></g><path fill="#6b4fbb" fill-rule="nonzero" d="m28.02 67.48c-15.927-2.825-28.02-16.738-28.02-33.476 0-18.778 15.222-34 34-34 18.778 0 34 15.222 34 34 0 16.738-12.1 30.652-28.02 33.476.015.173.023.347.023.524v21.999c0 3.314-2.693 6-6 6-3.314 0-6-2.682-6-6v-21.999c0-.177.008-.351.023-.524m5.977-7.476c14.359 0 26-11.641 26-26 0-14.359-11.641-26-26-26-14.359 0-26 11.641-26 26 0 14.359 11.641 26 26 26" transform="matrix(.70711-.70711.70711.70711 84.34 49.5)"/></g></svg>
diff --git a/app/views/shared/issuable/form/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
index 7ef0ae96be2..307d4919224 100644
--- a/app/views/shared/issuable/form/_description.html.haml
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -1,10 +1,11 @@
- project = local_assigns.fetch(:project)
-- issuable = local_assigns.fetch(:issuable)
+- model = local_assigns.fetch(:model)
+
- form = local_assigns.fetch(:form)
-- supports_slash_commands = issuable.new_record?
+- supports_slash_commands = model.new_record?
- if supports_slash_commands
- - preview_url = preview_markdown_path(project, slash_commands_target_type: issuable.class.name)
+ - preview_url = preview_markdown_path(project, slash_commands_target_type: model.class.name)
- else
- preview_url = preview_markdown_path(project)
diff --git a/app/views/shared/hook_logs/_content.html.haml b/app/views/shared/hook_logs/_content.html.haml
new file mode 100644
index 00000000000..af6a499fadb
--- /dev/null
+++ b/app/views/shared/hook_logs/_content.html.haml
@@ -0,0 +1,44 @@
+%p
+ %strong Request URL:
+ POST
+ = hook_log.url
+ = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log }
+
+%p
+ %strong Trigger:
+ %td.hidden-xs
+ %span.label.label-gray.deploy-project-label
+ = hook_log.trigger.singularize.titleize
+%p
+ %strong Elapsed time:
+ #{number_with_precision(hook_log.execution_duration, precision: 2)} ms
+%p
+ %strong Request time:
+ = time_ago_with_tooltip(hook_log.created_at)
+
+%hr
+
+- if hook_log.internal_error_message.present?
+ .bs-callout.bs-callout-danger
+ = hook_log.internal_error_message
+
+%h5 Request headers:
+%pre
+ - hook_log.request_headers.each do |k,v|
+ <strong>#{k}:</strong> #{v}
+ %br
+
+%h5 Request body:
+%pre
+ :plain
+ #{JSON.pretty_generate(hook_log.request_data)}
+%h5 Response headers:
+%pre
+ - hook_log.response_headers.each do |k,v|
+ <strong>#{k}:</strong> #{v}
+ %br
+
+%h5 Response body:
+%pre
+ :plain
+ #{hook_log.response_body}
diff --git a/app/views/shared/hook_logs/_status_label.html.haml b/app/views/shared/hook_logs/_status_label.html.haml
new file mode 100644
index 00000000000..b4ea8e6f952
--- /dev/null
+++ b/app/views/shared/hook_logs/_status_label.html.haml
@@ -0,0 +1,3 @@
+- label_status = hook_log.success? ? 'label-success' : 'label-danger'
+%span{ class: "label #{label_status}" }
+ = hook_log.response_status
diff --git a/app/views/shared/icons/_convdev_no_data.svg b/app/views/shared/icons/_convdev_no_data.svg
new file mode 100644
index 00000000000..ed32b2333e7
--- /dev/null
+++ b/app/views/shared/icons/_convdev_no_data.svg
@@ -0,0 +1,40 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="360" height="220" viewBox="0 0 360 220">
+ <g fill="none" fill-rule="evenodd">
+ <path fill="#000" fill-opacity=".02" d="M125 44V24.003C125 18.48 129.483 14 135.005 14h89.99C230.52 14 235 18.477 235 24.003V43h84.992C326.624 43 332 48.372 332 55.002v144.996c0 6.63-5.38 12.002-12.008 12.002h-85.984c-6.632 0-12.008-5.372-12.008-12.002V183h-78v17.002c0 6.626-5.38 11.998-12.008 11.998H46.008C39.376 212 34 206.624 34 200.002V55.998C34 49.372 39.38 44 46.008 44H125z"/>
+ <g transform="translate(214 36)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <path fill="#F0EDF8" fill-rule="nonzero" d="M57 111c-11.598 0-21-9.402-21-21s9.402-21 21-21 21 9.402 21 21-9.402 21-21 21zm0-4c9.39 0 17-7.61 17-17s-7.61-17-17-17-17 7.61-17 17 7.61 17 17 17z"/>
+ <path fill="#6B4FBB" d="M58 88v-6.997c0-1.11-.895-2.003-2-2.003-1.112 0-2 .897-2 2.003v8.994c0 1.11.895 2.003 2 2.003.174 0 .343-.022.503-.063.162.04.33.063.506.063h7.98C66.1 92 67 91.105 67 90c0-1.112-.9-2-2.01-2H58z"/>
+ <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/>
+ <path fill="#EEE" d="M21 16c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C21.895 18 21 17.112 21 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C34.895 18 34 17.112 34 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C47.895 18 47 17.112 47 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C60.895 18 60 17.112 60 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C73.895 18 73 17.112 73 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C86.895 18 86 17.112 86 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C99.895 18 99 17.112 99 16z"/>
+ </g>
+ <g transform="translate(118 7)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <g fill-rule="nonzero">
+ <path fill="#F0EDF8" d="M57 112c-12.15 0-22-9.85-22-22s9.85-22 22-22 22 9.85 22 22-9.85 22-22 22zm0-6c8.837 0 16-7.163 16-16s-7.163-16-16-16-16 7.163-16 16 7.163 16 16 16z"/>
+ <path fill="#6B4FBB" d="M41.692 105.8C45.768 109.75 51.21 112 57 112c12.15 0 22-9.85 22-22s-9.85-22-22-22v6c8.837 0 16 7.163 16 16s-7.163 16-16 16c-4.215 0-8.166-1.633-11.133-4.508l-4.175 4.31z"/>
+ </g>
+ <path fill="#EEE" d="M8 16c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2H9.998C8.895 18 8 17.112 8 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C21.895 18 21 17.112 21 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C34.895 18 34 17.112 34 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C47.895 18 47 17.112 47 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C60.895 18 60 17.112 60 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C73.895 18 73 17.112 73 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C86.895 18 86 17.112 86 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C99.895 18 99 17.112 99 16z"/>
+ </g>
+ <g transform="translate(26 36)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006v147.988C4 164.42 7.58 168 12.005 168h89.99c4.42 0 8.005-3.586 8.005-8.006V12.006C110 7.58 106.42 4 101.995 4h-89.99C7.585 4 4 7.586 4 12.006zm-4 0C0 5.376 5.377 0 12.005 0h89.99C108.628 0 114 5.37 114 12.006v147.988c0 6.63-5.377 12.006-12.005 12.006h-89.99C5.372 172 0 166.63 0 159.994V12.006z"/>
+ <g transform="translate(21 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(69 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(38 42)">
+ <rect width="22" height="4" x="8" fill="#FEE1D3" rx="2"/>
+ <rect width="38" height="4" y="12" fill="#FB722E" rx="2"/>
+ </g>
+ <path fill="#EEE" d="M4 14h106v4H4z"/>
+ <path fill="#333" d="M35.724 138h9.696v-2.856h-2.856V122.76h-2.592c-1.08.648-2.136 1.08-3.792 1.392v2.184h2.856v8.808h-3.312V138zm17.736.288c-2.952 0-5.76-2.208-5.76-7.56 0-5.688 2.952-8.256 6.168-8.256 2.016 0 3.48.84 4.44 1.824l-1.848 2.112c-.528-.576-1.488-1.08-2.376-1.08-1.68 0-3.024 1.2-3.144 4.752.792-1.008 2.112-1.608 3.048-1.608 2.616 0 4.536 1.488 4.536 4.704 0 3.168-2.304 5.112-5.064 5.112zm-.072-2.64c1.056 0 1.92-.744 1.92-2.472 0-1.608-.84-2.208-1.992-2.208-.792 0-1.68.432-2.304 1.512.312 2.4 1.32 3.168 2.376 3.168zM63.9 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_convdev_no_index.svg b/app/views/shared/icons/_convdev_no_index.svg
new file mode 100644
index 00000000000..95c00e81d10
--- /dev/null
+++ b/app/views/shared/icons/_convdev_no_index.svg
@@ -0,0 +1,67 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="360" height="200" viewBox="0 0 360 200">
+ <g fill="none" fill-rule="evenodd" transform="translate(3 11)">
+ <rect width="110" height="168" x="6" y="8" fill="#000" fill-opacity=".02" rx="10"/>
+ <g transform="translate(0 2)">
+ <rect width="110" height="168" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M2 10.006v147.988C2 162.42 5.58 166 10.005 166h89.99c4.42 0 8.005-3.586 8.005-8.006V10.006C108 5.58 104.42 2 99.995 2h-89.99C5.585 2 2 5.586 2 10.006zm-4 0C-2 3.376 3.377-2 10.005-2h89.99C106.628-2 112 3.37 112 10.006v147.988c0 6.63-5.377 12.006-12.005 12.006h-89.99C3.372 170-2 164.63-2 157.994V10.006z"/>
+ <g transform="translate(19 80)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(67 80)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(36 40)">
+ <rect width="22" height="4" x="8" fill="#FEE1D3" rx="2"/>
+ <rect width="38" height="4" y="12" fill="#FB722E" rx="2"/>
+ </g>
+ <path fill="#EEE" d="M2 12h106v4H2z"/>
+ <path fill="#333" d="M38.048 127.792c.792 0 1.68-.432 2.28-1.512-.312-2.4-1.296-3.168-2.376-3.168-1.032 0-1.92.744-1.92 2.472 0 1.608.864 2.208 2.016 2.208zm-.552 8.496c-2.016 0-3.504-.864-4.464-1.824l1.872-2.112c.504.576 1.464 1.08 2.352 1.08 1.704 0 3.024-1.2 3.144-4.752-.792 1.008-2.112 1.608-3.048 1.608-2.592 0-4.536-1.488-4.536-4.704 0-3.168 2.304-5.112 5.064-5.112 2.952 0 5.784 2.208 5.784 7.56 0 5.688-2.976 8.256-6.168 8.256zm13.488 0c-3.048 0-5.304-1.704-5.304-4.176 0-1.848 1.152-2.976 2.592-3.744v-.096c-1.176-.888-2.04-1.992-2.04-3.6 0-2.592 2.04-4.2 4.872-4.2 2.784 0 4.632 1.656 4.632 4.176 0 1.464-.936 2.64-1.992 3.336v.096c1.464.792 2.64 1.968 2.64 3.984 0 2.4-2.16 4.224-5.4 4.224zm.96-9.168c.6-.696.936-1.44.936-2.232 0-1.176-.696-1.968-1.848-1.968-.936 0-1.704.576-1.704 1.752 0 1.248 1.056 1.848 2.616 2.448zm-.888 6.72c1.176 0 2.04-.624 2.04-1.896 0-1.344-1.296-1.848-3.216-2.664-.672.624-1.176 1.488-1.176 2.424 0 1.344 1.08 2.136 2.352 2.136zm10.8-3.84c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/>
+ </g>
+ <g transform="translate(122)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <g transform="translate(21 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/>
+ </g>
+ <g transform="translate(69 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/>
+ </g>
+ <path fill="#FEE1D3" d="M44 44c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 46 44 45.112 44 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 46 54 45.112 54 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 46 64 45.112 64 44zM34 56c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C34.894 58 34 57.112 34 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 58 44 57.112 44 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 58 54 57.112 54 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 58 64 57.112 64 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C74.894 58 74 57.112 74 56z"/>
+ <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="21" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="34" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="47" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="60" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="73" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="86" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="99" y="14" fill="#EEE" rx="2"/>
+ <path fill="#EEE" d="M46.716 138.288c-3.264 0-5.448-2.784-5.448-7.968s2.184-7.848 5.448-7.848c3.264 0 5.448 2.664 5.448 7.848 0 5.184-2.184 7.968-5.448 7.968zm0-2.736c1.2 0 2.112-1.08 2.112-5.232 0-4.176-.912-5.112-2.112-5.112-1.176 0-2.112.936-2.112 5.112 0 4.152.936 5.232 2.112 5.232zM57.564 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/>
+ </g>
+ <g transform="translate(243)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <path fill="#FEE1D3" d="M44 44c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 46 44 45.112 44 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 46 54 45.112 54 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 46 64 45.112 64 44zM34 56c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C34.894 58 34 57.112 34 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 58 44 57.112 44 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 58 54 57.112 54 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 58 64 57.112 64 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C74.894 58 74 57.112 74 56z"/>
+ <g transform="translate(21 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/>
+ </g>
+ <g transform="translate(69 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/>
+ </g>
+ <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="21" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="34" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="47" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="60" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="73" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="86" y="14" fill="#EEE" rx="2"/>
+ <rect width="8" height="4" x="99" y="14" fill="#EEE" rx="2"/>
+ <path fill="#EEE" d="M46.716 138.288c-3.264 0-5.448-2.784-5.448-7.968s2.184-7.848 5.448-7.848c3.264 0 5.448 2.664 5.448 7.848 0 5.184-2.184 7.968-5.448 7.968zm0-2.736c1.2 0 2.112-1.08 2.112-5.232 0-4.176-.912-5.112-2.112-5.112-1.176 0-2.112.936-2.112 5.112 0 4.152.936 5.232 2.112 5.232zM57.564 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_convdev_overview.svg b/app/views/shared/icons/_convdev_overview.svg
new file mode 100644
index 00000000000..2f31113bad7
--- /dev/null
+++ b/app/views/shared/icons/_convdev_overview.svg
@@ -0,0 +1,64 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="208" height="127" viewBox="0 0 208 127" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <defs>
+ <rect id="a" width="58" height="98" y="17" rx="6"/>
+ <rect id="b" width="58" height="98" x="3.5" y="17" rx="6"/>
+ <rect id="c" width="58" height="98.394" rx="6"/>
+ </defs>
+ <g fill="none" fill-rule="evenodd" transform="translate(1)">
+ <path fill="#000" fill-opacity=".06" fill-rule="nonzero" d="M16 11.06c0-1.39.56-2.69 1.534-3.635.398-.386.41-1.025.027-1.426-.382-.402-1.015-.414-1.413-.028C14.785 7.294 14 9.116 14 11.062c0 .556.448 1.007 1 1.007s1-.452 1-1.01zm6.432-5.043h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0H185c1.21 0 2.354.435 3.254 1.215.42.362 1.05.314 1.41-.108.36-.423.312-1.06-.107-1.422C188.297 4.612 186.694 4 185 4h-.568c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zM190 11.932v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008zm0 10.89v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.01v-4.838c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01zm0 10.89v4.84c0 .555.448 1.007 1 1.007s1-.453 1-1.01v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.008zm0 10.888v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008V44.6c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008zm0 10.89v4.84c0 .556.448 1.007 1 1.007s1-.45 1-1.008v-4.84c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01zm0 10.89v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01zm0 10.888v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008zm0 10.89v4.84c0 .556.448 1.007 1 1.007s1-.45 1-1.008v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.007zm0 10.888v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008zm-.24 21.446c-.42 1.304-1.353 2.385-2.572 2.985-.497.244-.703.847-.46 1.348.24.5.84.708 1.336.464 1.707-.84 3.013-2.35 3.598-4.178.17-.53-.12-1.098-.644-1.27-.526-.17-1.09.12-1.26.65zm-8.063 3.49h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.577-.116c-1.33-.295-2.48-1.13-3.19-2.3-.287-.474-.902-.623-1.373-.333-.472.29-.62.91-.332 1.386.99 1.632 2.6 2.8 4.465 3.215.54.12 1.073-.224 1.192-.768.12-.544-.222-1.082-.762-1.2zM16 105.292v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01zm0-10.89v-4.84c0-.555-.448-1.007-1-1.007s-1 .452-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.007zm0-10.888v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-10.89v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.008v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008zm0-10.89v-4.838c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.01zm0-11.888v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-9.89v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.007v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-10.888v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008zm0-10.89v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01z"/>
+ <g transform="translate(74)">
+ <rect width="58" height="98" y="20" fill="#000" fill-opacity=".02" rx="6"/>
+ <use fill="#FFF" xlink:href="#a"/>
+ <rect width="56" height="96" x="1" y="18" stroke="#EEE" stroke-width="2" rx="6"/>
+ <g transform="translate(16 45.185)">
+ <path fill="#333" d="M.59 33.815h5.655V32.15H4.58v-7.225H3.066c-.63.378-1.246.63-2.212.812v1.274H2.52v5.14H.59v1.665zm10.093.168c-1.778 0-3.094-.994-3.094-2.436 0-1.078.67-1.736 1.51-2.184v-.056c-.685-.518-1.19-1.162-1.19-2.1 0-1.512 1.19-2.45 2.843-2.45 1.624 0 2.702.966 2.702 2.436 0 .854-.546 1.54-1.162 1.946v.055c.854.462 1.54 1.148 1.54 2.324 0 1.4-1.26 2.463-3.15 2.463zm.56-5.348c.35-.406.546-.84.546-1.302 0-.686-.407-1.148-1.08-1.148-.545 0-.993.336-.993 1.022 0 .728.616 1.078 1.526 1.428zm-.518 3.92c.686 0 1.19-.364 1.19-1.106 0-.785-.756-1.08-1.876-1.555-.393.364-.687.868-.687 1.414 0 .783.63 1.245 1.372 1.245zm6.3-2.24c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.063 2.282 2.883 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.462.84-1.764s-.378-1.736-.84-1.736c-.462 0-.84.434-.84 1.736s.378 1.764.84 1.764zm.308 4.816l4.928-9.464h1.19l-4.927 9.463h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.883 2.27-2.883 1.315 0 2.28 1.064 2.28 2.884 0 1.835-.965 2.913-2.28 2.913zm0-1.148c.46 0 .84-.462.84-1.764 0-1.3-.38-1.735-.84-1.735-.463 0-.84.434-.84 1.736 0 1.303.377 1.765.84 1.765z"/>
+ <rect width="13" height="2" x="6" y=".815" fill="#FB722E" rx="1"/>
+ <path fill="#F0EDF8" d="M3 47.815c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1zm0 6c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1z"/>
+ <rect width="20" height="2" x="3" y="6.815" fill="#FEE1D3" rx="1"/>
+ </g>
+ <g transform="translate(10.81)">
+ <circle cx="18.19" cy="18" r="18" fill="#FFF"/>
+ <path fill="#F0EDF8" fill-rule="nonzero" d="M18.19 34c8.837 0 16-7.163 16-16s-7.163-16-16-16-16 7.163-16 16 7.163 16 16 16zm0 2c-9.94 0-18-8.06-18-18s8.06-18 18-18 18 8.06 18 18-8.06 18-18 18z"/>
+ <g transform="translate(10 11)">
+ <path fill="#C3B8E3" fill-rule="nonzero" d="M2.19 13.32L5.397 11h7.783c.566 0 1.01-.444 1.01-1V3c0-.55-.45-1-1.01-1H3.2c-.566 0-1.01.444-1.01 1v10.32zM6.045 13l-3.422 2.476C1.28 16.45.19 15.892.19 14.23V3c0-1.657 1.337-3 3.01-3h9.98c1.663 0 3.01 1.342 3.01 3v7c0 1.657-1.337 3-3.01 3H6.045z"/>
+ <rect width="4" height="2" x="5.19" y="4" fill="#6B4FBB" rx="1"/>
+ <rect width="6" height="2" x="5.19" y="7" fill="#6B4FBB" rx="1"/>
+ </g>
+ </g>
+ </g>
+ <g transform="translate(144.5)">
+ <rect width="58" height="98" x=".5" y="20" fill="#000" fill-opacity=".02" rx="6"/>
+ <use fill="#FFF" xlink:href="#b"/>
+ <rect width="56" height="96" x="4.5" y="18" stroke="#EEE" stroke-width="2" rx="6"/>
+ <g transform="translate(19 46.185)">
+ <path fill="#333" d="M4.01 33.746c1.793 0 3.305-.938 3.305-2.59 0-1.148-.742-1.876-1.764-2.17v-.056c.953-.406 1.485-1.05 1.485-1.974 0-1.554-1.232-2.436-3.066-2.436-1.093 0-1.99.434-2.8 1.134l1.035 1.26c.56-.49 1.036-.784 1.666-.784.7 0 1.093.364 1.093.98 0 .714-.504 1.19-2.1 1.19v1.456c1.932 0 2.394.49 2.394 1.274 0 .672-.574 1.05-1.442 1.05-.756 0-1.414-.378-1.946-.896l-.953 1.302c.644.756 1.652 1.26 3.094 1.26zm4.51-.168h6.257v-1.736h-1.792c-.42 0-1.036.056-1.484.112 1.443-1.512 2.843-3.108 2.843-4.606 0-1.708-1.19-2.828-2.94-2.828-1.274 0-2.1.476-2.982 1.414l1.12 1.106c.45-.476.94-.91 1.583-.91.77 0 1.26.476 1.26 1.344 0 1.26-1.596 2.786-3.864 4.928v1.176zm9.505-3.5c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.064 2.282 2.884 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.462.84-1.764s-.378-1.736-.84-1.736c-.462 0-.84.434-.84 1.736s.378 1.764.84 1.764zm.308 4.816l4.928-9.464h1.19l-4.927 9.464h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.884 2.27-2.884 1.315 0 2.28 1.064 2.28 2.884 0 1.834-.965 2.912-2.28 2.912zm0-1.148c.46 0 .84-.462.84-1.764s-.38-1.736-.84-1.736c-.463 0-.84.434-.84 1.736s.377 1.764.84 1.764z"/>
+ <rect width="13" height="2.008" x="7.5" fill="#FB722E" rx="1.004"/>
+ <path fill="#F0EDF8" d="M3.5 47.19c0-.556.455-1.005 1.006-1.005h17.988c.556 0 1.006.445 1.006 1.004 0 .553-.455 1.003-1.006 1.003H4.506c-.556 0-1.006-.446-1.006-1.004zm0 6.023c0-.555.455-1.004 1.006-1.004h17.988c.556 0 1.006.444 1.006 1.003 0 .554-.455 1.004-1.006 1.004H4.506c-.556 0-1.006-.446-1.006-1.004z"/>
+ <rect width="20" height="2.008" x="4" y="6.024" fill="#FEE1D3" rx="1.004"/>
+ </g>
+ <g transform="translate(14.413)">
+ <circle cx="18.087" cy="18" r="18" fill="#FFF"/>
+ <path fill="#F0EDF8" fill-rule="nonzero" d="M18.087 34c8.836 0 16-7.163 16-16s-7.164-16-16-16c-8.837 0-16 7.163-16 16s7.163 16 16 16zm0 2c-9.942 0-18-8.06-18-18s8.058-18 18-18c9.94 0 18 8.06 18 18s-8.06 18-18 18z"/>
+ <path fill="#C3B8E3" fill-rule="nonzero" d="M18.087 24c3.313 0 6-2.686 6-6s-2.687-6-6-6c-3.314 0-6 2.686-6 6s2.686 6 6 6zm0 2c-4.42 0-8-3.582-8-8s3.58-8 8-8c4.418 0 8 3.582 8 8s-3.582 8-8 8z"/>
+ <path fill="#6B4FBB" d="M19.087 17v-2c0-.556-.448-1-1-1-.557 0-1 .448-1 1v3c0 .278.11.528.292.71.18.18.43.29.706.29h3c.557 0 1-.448 1-1 0-.556-.447-1-1-1h-2z"/>
+ </g>
+ </g>
+ <rect width="58" height="98" x="3" y="20" fill="#000" fill-opacity=".02" rx="6"/>
+ <g transform="translate(0 16.754)">
+ <use fill="#FFF" xlink:href="#c"/>
+ <rect width="56" height="96.394" x="1" y="1" stroke="#EEE" stroke-width="2" rx="6"/>
+ <g transform="translate(16 29.618)">
+ <path fill="#333" d="M3.137 27.84c.462 0 .98-.253 1.33-.883-.182-1.4-.756-1.848-1.386-1.848-.6 0-1.12.433-1.12 1.44 0 .94.505 1.29 1.177 1.29zm-.322 4.955C1.64 32.795.77 32.29.21 31.73l1.093-1.23c.294.335.854.63 1.372.63.994 0 1.764-.7 1.834-2.773-.463.588-1.233.938-1.78.938-1.51 0-2.645-.868-2.645-2.744 0-1.847 1.344-2.98 2.954-2.98 1.72 0 3.373 1.287 3.373 4.41 0 3.317-1.736 4.815-3.598 4.815zm8.12 0c-1.722 0-3.36-1.288-3.36-4.41 0-3.318 1.722-4.816 3.598-4.816 1.176 0 2.03.49 2.59 1.063l-1.078 1.232c-.308-.336-.868-.63-1.386-.63-.98 0-1.765.7-1.835 2.772.462-.588 1.232-.938 1.778-.938 1.526 0 2.646.867 2.646 2.743 0 1.848-1.345 2.982-2.955 2.982zm-.042-1.54c.616 0 1.12-.434 1.12-1.442 0-.938-.49-1.288-1.162-1.288-.46 0-.98.252-1.343.882.182 1.4.77 1.848 1.386 1.848zm6.132-2.128c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.065 2.282 2.885 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.463.84-1.765 0-1.302-.378-1.736-.84-1.736-.462 0-.84.433-.84 1.735s.378 1.764.84 1.764zm.308 4.815l4.928-9.464h1.19l-4.927 9.465h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.884 2.27-2.884 1.315 0 2.28 1.063 2.28 2.883 0 1.834-.965 2.912-2.28 2.912zm0-1.148c.46 0 .84-.462.84-1.764s-.38-1.736-.84-1.736c-.463 0-.84.434-.84 1.736s.377 1.764.84 1.764z"/>
+ <rect width="13" height="2.008" x="6.5" y=".314" fill="#FEE1D3" rx="1.004"/>
+ <path fill="#F0EDF8" d="M3 46.627c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1zm0 6c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1z"/>
+ <rect width="20" height="2" x="3" y="5.627" fill="#FB722E" rx="1"/>
+ </g>
+ </g>
+ <g transform="translate(10.41)">
+ <circle cx="18.589" cy="18" r="18" fill="#FFF"/>
+ <path fill="#F0EDF8" fill-rule="nonzero" d="M18.59 34c8.836 0 16-7.163 16-16s-7.164-16-16-16c-8.837 0-16 7.163-16 16s7.163 16 16 16zm0 2c-9.942 0-18-8.06-18-18s8.058-18 18-18c9.94 0 18 8.06 18 18s-8.06 18-18 18z"/>
+ <path fill="#C3B8E3" d="M17.05 19.262h3.367l.248-2.808H17.3l-.25 2.808zm-.177 2.008l-.144 1.627c-.06.662-.646 1.2-1.3 1.2h.25c-.658 0-1.144-.534-1.085-1.2l.144-1.627H13.59c-.554 0-1.003-.446-1.003-1.004 0-.555.455-1.004 1.002-1.004h1.325l.248-2.808h-1.15c-.555 0-1.004-.445-1.004-1.004 0-.554.457-1.004 1.004-1.004h1.33l.106-1.2c.058-.66.644-1.198 1.298-1.198h-.25c.66 0 1.145.533 1.086 1.2l-.106 1.198h3.365l.107-1.2c.058-.66.644-1.198 1.298-1.198h-.25c.66 0 1.145.533 1.086 1.2l-.106 1.198h1.03c.554 0 1.003.446 1.003 1.004 0 .555-.455 1.004-1 1.004H22.8l-.25 2.808h1.037c.554 0 1.002.446 1.002 1.004 0 .554-.456 1.004-1.003 1.004h-1.214l-.144 1.627c-.06.662-.646 1.2-1.3 1.2h.25c-.658 0-1.144-.534-1.085-1.2l.144-1.627h-3.367z"/>
+ <path fill="#6B4FBB" d="M17.05 19.262l-.177 2.008H14.74l.177-2.008h2.134zm-1.707-4.816h2.135l-.178 2.008h-2.135l.178-2.008zm5.5 0h2.135l-.178 2.008h-2.135l.178-2.008zm1.708 4.816l-.177 2.008H20.24l.177-2.008h2.134z"/>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_1.svg b/app/views/shared/icons/_i2p_step_1.svg
new file mode 100644
index 00000000000..9dedcd5291a
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_1.svg
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m45.688 18.854c-4.869-1.989-10.488-1.975-15.29-.001-2.413.979-4.597 2.414-6.493 4.268-1.836 1.8-3.33 3.985-4.346 6.381-1.013 2.38-1.525 4.916-1.525 7.537 0 2.066.33 4.118.983 6.104.469 1.388 1.089 2.706 1.83 3.937-1.275 1.101-2.086 2.725-2.086 4.538 0 3.309 2.691 6 6 6s6-2.691 6-6-2.691-6-6-6c-.779 0-1.522.154-2.205.425-.665-1.105-1.221-2.289-1.642-3.533-.585-1.776-.881-3.618-.881-5.472 0-2.351.459-4.623 1.391-6.814.89-2.096 2.231-4.059 3.88-5.675 1.708-1.669 3.675-2.962 5.85-3.845 4.329-1.778 9.392-1.79 13.78.002 2.17.881 4.137 2.175 5.843 3.84 3.39 3.34 5.257 7.776 5.257 12.493.002 1.86-.294 3.705-.878 5.481-.579 1.75-1.443 3.406-2.569 4.923-2.134 2.866-3.818 4.698-5.174 6.173-2.424 2.643-3.98 4.599-4.383 8.384h-10.815c-.553 0-1 .447-1 1s.447 1 1 1h11.739c.532 0 .971-.416.999-.947.19-3.645 1.345-5.263 3.934-8.09 1.385-1.506 3.107-3.381 5.304-6.331 1.254-1.688 2.218-3.535 2.864-5.489.651-1.98.98-4.04.979-6.109 0-5.256-2.078-10.198-5.856-13.92-1.897-1.851-4.081-3.287-6.49-4.265m-16.927 32.763c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4"/>
+ <path d="m40 74h-4c-.553 0-1 .447-1 1s.447 1 1 1h4c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m42 70h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m38 10c.553 0 1-.447 1-1v-8c0-.553-.447-1-1-1s-1 .447-1 1v8c0 .553.447 1 1 1"/>
+ <path d="m20.828 15.828c.256 0 .512-.098.707-.293.391-.391.391-1.023 0-1.414l-5.656-5.656c-.391-.391-1.023-.391-1.414 0s-.391 1.023 0 1.414l5.656 5.656c.195.195.451.293.707.293"/>
+ <path d="m10 33h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m60.12 8.465l-5.656 5.656c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l5.656-5.656c.391-.391.391-1.023 0-1.414s-1.023-.391-1.414 0"/>
+ <path d="m74 33h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m43 66h-10c-.553 0-1 .447-1 1s.447 1 1 1h10c.553 0 1-.447 1-1s-.447-1-1-1"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_10.svg b/app/views/shared/icons/_i2p_step_10.svg
new file mode 100644
index 00000000000..dd6fd1457ff
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_10.svg
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m5 43c0 .553.447 1 1 1s1-.447 1-1v-4h4c.553 0 1-.447 1-1s-.447-1-1-1h-4v-4c0-.553-.447-1-1-1s-1 .447-1 1v4h-4c-.553 0-1 .447-1 1s.447 1 1 1h4v4"/>
+ <path d="m75 37h-4v-4c0-.553-.447-1-1-1s-1 .447-1 1v4h-4c-.553 0-1 .447-1 1s.447 1 1 1h4v4c0 .553.447 1 1 1s1-.447 1-1v-4h4c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m21 38c0 .345.178.665.47.848l8 5c.165.103.348.152.529.152.333 0 .659-.166.849-.47.293-.469.15-1.086-.317-1.378l-6.644-4.152 6.644-4.152c.468-.292.61-.909.317-1.378s-.908-.611-1.378-.317l-8 5c-.292.182-.47.502-.47.847"/>
+ <path d="m55 38c0-.345-.178-.665-.47-.848l-8-5c-.469-.294-1.086-.151-1.378.317-.293.469-.15 1.086.317 1.378l6.644 4.153-6.644 4.152c-.468.292-.61.909-.317 1.378.189.304.516.47.849.47.181 0 .364-.049.529-.152l8-5c.292-.183.47-.503.47-.848"/>
+ <path d="m41.803 26.05c-.525-.168-1.089.124-1.256.65l-7 22c-.167.525.124 1.088.65 1.256.101.032.202.047.303.047.424 0 .817-.271.953-.697l7-22c.167-.526-.124-1.088-.65-1.256"/>
+ <path d="m62 7c3.859 0 7 3.141 7 7v11c0 .553.447 1 1 1s1-.447 1-1v-11c0-4.963-4.04-9-9-9h-16.09c-.479-2.833-2.943-5-5.91-5-3.309 0-6 2.691-6 6s2.691 6 6 6c2.967 0 5.431-2.167 5.91-5h16.09m-22 3c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/>
+ <path d="m6 26c.553 0 1-.447 1-1v-11c0-3.859 3.141-7 7-7h11.09l-3.293 3.293c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l5-5c.391-.391.391-1.023 0-1.414l-5-5c-.391-.391-1.023-.391-1.414 0s-.391 1.023 0 1.414l3.293 3.293h-11.09c-4.963 0-9 4.04-9 9v11c0 .553.447 1 1 1"/>
+ <path d="m36 64c-2.967 0-5.431 2.167-5.91 5h-16.09c-3.859 0-7-3.141-7-7v-11c0-.553-.447-1-1-1s-1 .447-1 1v11c0 4.963 4.04 9 9 9h16.09c.478 2.833 2.942 5 5.91 5 3.309 0 6-2.691 6-6s-2.691-6-6-6m0 10c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/>
+ <path d="m70 50c-.553 0-1 .447-1 1v11c0 3.859-3.141 7-7 7h-11.09l3.293-3.293c.391-.391.391-1.023 0-1.414s-1.023-.391-1.414 0l-5 5c-.391.391-.391 1.023 0 1.414l5 5c.195.195.451.293.707.293s.512-.098.707-.293c.391-.391.391-1.023 0-1.414l-3.293-3.293h11.09c4.963 0 9-4.04 9-9v-11c0-.553-.447-1-1-1"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_2.svg b/app/views/shared/icons/_i2p_step_2.svg
new file mode 100644
index 00000000000..b8805b90275
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_2.svg
@@ -0,0 +1,5 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m42.26 40.44c.558.073 1.045-.329 1.109-.877l2.625-22.444c.033-.283-.057-.567-.246-.781-.189-.214-.462-.336-.747-.336h-14c-.284 0-.555.121-.744.332-.19.212-.281.494-.25.776l3.454 31.575c-1.503 1.285-2.46 3.19-2.46 5.317 0 3.859 3.141 7 7 7s7-3.141 7-7-3.141-7-7-7c-.94 0-1.835.189-2.655.527l-3.23-29.527h11.761l-2.494 21.328c-.065.549.328 1.045.877 1.11m.741 13.562c0 2.757-2.243 5-5 5s-5-2.243-5-5 2.243-5 5-5 5 2.243 5 5"/>
+ <path d="M73.236,23.749c-0.207-0.513-0.796-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_3.svg b/app/views/shared/icons/_i2p_step_3.svg
new file mode 100644
index 00000000000..6c783ed8289
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_3.svg
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m12 8c0-3.309-2.691-6-6-6s-6 2.691-6 6c0 2.967 2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909s2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909s2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909 0 3.309 2.691 6 6 6s6-2.691 6-6c0-2.967-2.167-5.431-5-5.91v-8.18c2.833-.478 5-2.942 5-5.91s-2.167-5.431-5-5.91v-8.18c2.833-.478 5-2.942 5-5.91s-2.167-5.431-5-5.91v-8.18c2.833-.479 5-2.943 5-5.91m-10 0c0-2.206 1.794-4 4-4s4 1.794 4 4-1.794 4-4 4-4-1.794-4-4m8 60c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4m0-20c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4m0-20c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4"/>
+ <path d="m21 6h54c.553 0 1-.447 1-1s-.447-1-1-1h-54c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m21 12h35c.553 0 1-.447 1-1s-.447-1-1-1h-35c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m75 24h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m21 32h34c.553 0 1-.447 1-1s-.447-1-1-1h-34c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m75 44h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m21 52h34c.553 0 1-.447 1-1s-.447-1-1-1h-34c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m75 64h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m55 70h-34c-.553 0-1 .447-1 1s.447 1 1 1h34c.553 0 1-.447 1-1s-.447-1-1-1"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_4.svg b/app/views/shared/icons/_i2p_step_4.svg
new file mode 100644
index 00000000000..af804c838e0
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_4.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m67.7 10h-6.751c-.507-5.598-5.221-10-10.949-10-6.06 0-11 4.935-11 11s4.935 11 11 11c5.728 0 10.442-4.402 10.949-10h6.751c1.269 0 2.3.987 2.3 2.2v57.6c0 1.213-1.031 2.2-2.3 2.2h-59.4c-1.269 0-2.3-.987-2.3-2.2v-57.6c0-1.213 1.031-2.2 2.3-2.2h15.15c.553 0 1-.447 1-1s-.447-1-1-1h-15.15c-2.371 0-4.3 1.884-4.3 4.2v57.6c0 2.316 1.929 4.2 4.3 4.2h59.4c2.371 0 4.3-1.884 4.3-4.2v-57.6c0-2.316-1.929-4.2-4.3-4.2m-17.7 10c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="m21.293 29.29c-.391.391-.391 1.023 0 1.414l12.975 12.975-12.975 12.974c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l13.682-13.682c.391-.391.391-1.023 0-1.414l-13.682-13.681c-.391-.391-1.023-.391-1.414 0"/>
+ <path d="m54 59c.553 0 1-.447 1-1s-.447-1-1-1h-12c-.553 0-1 .447-1 1s.447 1 1 1h12"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_5.svg b/app/views/shared/icons/_i2p_step_5.svg
new file mode 100644
index 00000000000..e54f707019e
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_5.svg
@@ -0,0 +1,5 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m48.949 37c-.507-5.598-5.221-10-10.949-10s-10.442 4.402-10.949 10h-13.05c-.553 0-1 .447-1 1s.447 1 1 1h13.05c.507 5.598 5.221 10 10.949 10s10.442-4.402 10.949-10h12.24c.553 0 1-.447 1-1s-.447-1-1-1h-12.24m-10.949 10c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="M73.236,23.749c-0.207-0.513-0.797-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_6.svg b/app/views/shared/icons/_i2p_step_6.svg
new file mode 100644
index 00000000000..c57baccc06b
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_6.svg
@@ -0,0 +1,15 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m14.267 7.32l-4.896 5.277-1.702-1.533c-.409-.369-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/>
+ <path d="m31 9h44c.553 0 1-.447 1-1s-.447-1-1-1h-44c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m31 15h24c.553 0 1-.447 1-1s-.447-1-1-1h-24c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m11 0c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="m14.267 34.32l-4.896 5.277-1.702-1.533c-.409-.368-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/>
+ <path d="m75 34h-44c-.553 0-1 .447-1 1s.447 1 1 1h44c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m31 42h24c.553 0 1-.447 1-1s-.447-1-1-1h-24c-.553 0-1 .447-1 1s.447 1 1 1"/>
+ <path d="m11 27c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="m14.267 61.32l-4.896 5.277-1.702-1.533c-.409-.368-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/>
+ <path d="m11 54c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/>
+ <path d="m75 61h-44c-.553 0-1 .447-1 1s.447 1 1 1h44c.553 0 1-.447 1-1s-.447-1-1-1"/>
+ <path d="m55 67h-24c-.553 0-1 .447-1 1s.447 1 1 1h24c.553 0 1-.447 1-1s-.447-1-1-1"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_7.svg b/app/views/shared/icons/_i2p_step_7.svg
new file mode 100644
index 00000000000..e9083de3afa
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_7.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="M73.236,23.749c-0.208-0.513-0.798-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/>
+ <path d="m27.19 32.17c-.277-.479-.89-.643-1.366-.364l-12.654 7.326c-.309.179-.499.509-.499.865s.19.687.499.865l12.654 7.326c.157.092.33.135.5.135.345 0 .681-.179.866-.499.277-.478.113-1.09-.364-1.366l-11.159-6.461 11.159-6.461c.478-.276.642-.889.364-1.366"/>
+ <path d="m48.808 47.827c.186.32.521.499.866.499.17 0 .343-.043.5-.135l12.654-7.326c.309-.179.499-.509.499-.865s-.19-.687-.499-.865l-12.654-7.326c-.478-.278-1.09-.114-1.366.364-.277.478-.113 1.09.364 1.366l11.159 6.461-11.159 6.461c-.478.276-.642.889-.364 1.366"/>
+ <path d="m42.71 23.06l-11.312 33.23c-.179.522.102 1.091.624 1.269.106.037.216.054.322.054.416 0 .805-.262.946-.678l11.312-33.23c.179-.522-.102-1.091-.624-1.269-.523-.181-1.089.101-1.268.624"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_8.svg b/app/views/shared/icons/_i2p_step_8.svg
new file mode 100644
index 00000000000..62676b0e12e
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_8.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m62.44 54.765l-9.912-11.09c.315-3.881.481-7.241.508-10.271-.029-13.871-3.789-23.05-13.413-32.746-.855-.859-2.411-.828-3.294.059-7.594 7.65-11.139 13.934-12.575 22.3-1.776.062-3.437.776-4.699 2.039-1.321 1.321-2.05 3.079-2.05 4.949s.729 3.628 2.051 4.949c1.321 1.322 3.079 2.051 4.949 2.051s3.628-.729 4.949-2.051c1.322-1.321 2.051-3.079 2.051-4.949 0-1.869-.729-3.627-2.051-4.949-.9-.9-2-1.517-3.205-1.824 1.373-7.859 4.764-13.818 11.999-21.11.128-.13.356-.158.456-.059 9.207 9.274 12.805 18.06 12.832 31.33-.026 3.079-.202 6.527-.536 10.54-.023.273.067.545.25.749l10.166 11.379c.062.076.109.23.093.32l-4.547 17.407c-.004.015-.009.036-.079.106-.062.063-.155.1-.2.106l-3.577.002c-.144-.009-.265-.077-.309-.153l-5.425-10.328c-.173-.329-.515-.535-.886-.535h-15.962c-.371 0-.713.206-.886.535l-5.407 10.303-.069.072c-.07.07-.165.105-.199.105l-3.588.001c-.179-.009-.304-.123-.33-.227l-4.531-17.338c-.029-.146.019-.301.049-.34l10.197-11.415c.367-.412.332-1.044-.08-1.412-.411-.366-1.042-.333-1.412.08l-10.229 11.453c-.448.554-.63 1.312-.474 2.084l4.544 17.396c.253.963 1.146 1.669 2.218 1.719h3.636c.581 0 1.187-.261 1.615-.693.114-.114.286-.286.406-.528l5.144-9.793h14.754l5.16 9.822c.396.697 1.124 1.143 2.01 1.192l3.712-.003c.604-.046 1.137-.285 1.544-.694.313-.316.504-.646.598-1.022l4.557-17.451c.143-.718-.039-1.476-.518-2.066m-33.435-24.765c0 1.335-.521 2.591-1.465 3.535s-2.2 1.465-3.535 1.465-2.591-.521-3.535-1.465-1.465-2.2-1.465-3.535.521-2.591 1.465-3.535 2.2-1.465 3.535-1.465 2.591.521 3.535 1.465 1.465 2.2 1.465 3.535"/>
+</svg>
diff --git a/app/views/shared/icons/_i2p_step_9.svg b/app/views/shared/icons/_i2p_step_9.svg
new file mode 100644
index 00000000000..e4285a14425
--- /dev/null
+++ b/app/views/shared/icons/_i2p_step_9.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76">
+ <path d="m68 67c-1.725 0-3.36.541-4.723 1.545-2.298-4.02-6.592-6.545-11.277-6.545-2.734 0-5.359.853-7.555 2.43l-2.286-15.43h1.228l3.829 7.645c.339.598.962.979 1.724 1.022l2.812-.003c.507-.039.974-.25 1.316-.595.264-.266.433-.559.514-.882l3.433-13.145c.12-.611-.037-1.258-.449-1.763l-7.385-8.268c.231-2.875.354-5.376.374-7.641-.023-10.507-2.871-17.462-10.162-24.806-.737-.742-2.072-.715-2.829.044-5.617 5.659-8.309 10.336-9.446 16.463-1.267.186-2.438.764-3.36 1.686-1.134 1.134-1.758 2.64-1.758 4.243s.624 3.109 1.758 4.242c1.133 1.134 2.639 1.758 4.242 1.758s3.109-.624 4.242-1.758c1.134-1.133 1.758-2.639 1.758-4.242s-.624-3.109-1.758-4.242c-.858-.859-1.932-1.424-3.098-1.648 1.095-5.538 3.637-9.855 8.83-15.14 6.874 6.924 9.561 13.485 9.581 23.392-.021 2.316-.151 4.903-.402 7.91-.023.273.067.544.25.749l7.663 8.572-3.391 13.07-2.695.036-4.081-8.15c-.17-.339-.516-.553-.895-.553h-12.01c-.379 0-.725.214-.895.553l-4.04 8.114-2.707.015-3.427-13.07 7.671-8.588c.367-.412.332-1.044-.08-1.412-.411-.366-1.043-.333-1.412.08l-7.7 8.623c-.383.47-.54 1.116-.406 1.787l3.419 13.08c.216.829.98 1.438 1.907 1.48h2.735c.508 0 1.016-.218 1.391-.595.091-.09.242-.241.358-.475l3.804-7.597h1.228l-2.286 15.43c-2.196-1.577-4.821-2.43-7.555-2.43-4.685 0-8.979 2.53-11.277 6.545-1.363-1-2.998-1.545-4.723-1.545-4.411 0-8 3.589-8 8 0 .553.447 1 1 1h74c.553 0 1-.447 1-1 0-4.411-3.589-8-8-8m-36-44c0 1.068-.416 2.072-1.172 2.828-1.512 1.512-4.145 1.512-5.656 0-.756-.756-1.172-1.76-1.172-2.828s.416-2.072 1.172-2.828 1.76-1.172 2.828-1.172 2.072.416 2.828 1.172 1.172 1.76 1.172 2.828m-29.917 51c.478-2.834 2.949-5 5.917-5 1.638 0 3.17.652 4.313 1.836.231.24.562.35.895.29.327-.058.604-.274.739-.579 1.765-3.977 5.711-6.547 10.05-6.547 2.836 0 5.532 1.085 7.593 3.055.271.258.665.345 1.016.224.354-.122.61-.43.665-.8l2.588-17.479h4.275l2.589 17.479c.055.37.312.678.665.8s.745.035 1.016-.224c2.061-1.97 4.757-3.055 7.593-3.055 4.343 0 8.288 2.57 10.05 6.547.135.305.412.521.739.579.329.059.663-.051.895-.29 1.143-1.184 2.675-1.836 4.313-1.836 2.968 0 5.439 2.166 5.917 5h-71.834"/>
+</svg>
diff --git a/app/views/shared/icons/_icon_history.svg b/app/views/shared/icons/_icon_history.svg
new file mode 100644
index 00000000000..41096da19c5
--- /dev/null
+++ b/app/views/shared/icons/_icon_history.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="1792" height="1792" viewBox="0 0 1792 1792"><path d="M1664 896q0 156-61 298t-164 245-245 164-298 61q-172 0-327-72.5T305 1387q-7-10-6.5-22.5t8.5-20.5l137-138q10-9 25-9 16 2 23 12 73 95 179 147t225 52q104 0 198.5-40.5T1258 1258t109.5-163.5T1408 896t-40.5-198.5T1258 534t-163.5-109.5T896 384q-98 0-188 35.5T548 521l137 138q31 30 14 69-17 40-59 40H192q-26 0-45-19t-19-45V256q0-42 40-59 39-17 69 14l130 129q107-101 244.5-156.5T896 128q156 0 298 61t245 164 164 245 61 298zm-640-288v448q0 14-9 23t-23 9H672q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h224V608q0-14 9-23t23-9h64q14 0 23 9t9 23z"/></svg>
diff --git a/app/views/shared/icons/_icon_status_skipped.svg b/app/views/shared/icons/_icon_status_skipped.svg
index 1998dfef9ea..a9ba29c922c 100755
--- a/app/views/shared/icons/_icon_status_skipped.svg
+++ b/app/views/shared/icons/_icon_status_skipped.svg
@@ -1 +1 @@
-<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"/><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="#FFF"/><path d="M7.69 7.7l-.905.905a.7.7 0 0 0 .99.99l1.85-1.85c.411-.412.411-1.078 0-1.49l-1.85-1.85a.7.7 0 0 0-.99.99l.905.905H4.48a.7.7 0 0 0 0 1.4h3.21z"/></g></svg>
+<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M7 14A7 7 0 1 1 7 0a7 7 0 0 1 0 14z"/><path d="M7 13A6 6 0 1 0 7 1a6 6 0 0 0 0 12z" fill="#FFF" fill-rule="nonzero"/><path d="M6.415 7.04L4.579 5.203a.295.295 0 0 1 .004-.416l.349-.349a.29.29 0 0 1 .416-.004l2.214 2.214a.289.289 0 0 1 .019.021l.132.133c.11.11.108.291 0 .398L5.341 9.573a.282.282 0 0 1-.398 0l-.331-.331a.285.285 0 0 1 0-.399L6.415 7.04zm2.54 0L7.119 5.203a.295.295 0 0 1 .004-.416l.349-.349a.29.29 0 0 1 .416-.004l2.214 2.214a.289.289 0 0 1 .019.021l.132.133c.11.11.108.291 0 .398L7.881 9.573a.282.282 0 0 1-.398 0l-.331-.331a.285.285 0 0 1 0-.399L8.955 7.04z"/></svg>
diff --git a/app/views/shared/icons/_icon_status_skipped_borderless.svg b/app/views/shared/icons/_icon_status_skipped_borderless.svg
index fb3e930b3cb..3c8a26d7f4d 100644
--- a/app/views/shared/icons/_icon_status_skipped_borderless.svg
+++ b/app/views/shared/icons/_icon_status_skipped_borderless.svg
@@ -1 +1 @@
-<svg width="22px" height="22px" viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M12.0846,12.1 L10.6623,13.5223 C10.2454306,13.9539168 10.2513924,14.6399933 10.6756996,15.0643004 C11.1000067,15.4886076 11.7860832,15.4945694 12.2177,15.0777 L15.1261,12.1693 C15.7708612,11.5230891 15.7708612,10.4769109 15.1261,9.8307 L12.2177,6.9223 C11.7860832,6.50543057 11.1000067,6.51139239 10.6756996,6.93569957 C10.2513924,7.36000675 10.2454306,8.04608322 10.6623,8.4777 L12.0846,9.9 L7.04,9.9 C6.43248678,9.9 5.94,10.3924868 5.94,11 C5.94,11.6075132 6.43248678,12.1 7.04,12.1 L12.0846,12.1 L12.0846,12.1 Z" id="Shape"></path></svg>
+<svg width="22" height="22" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"><path d="M14.072 11.063l-2.82 2.82a.46.46 0 0 0-.001.652l.495.495a.457.457 0 0 0 .653-.001l3.7-3.7a.46.46 0 0 0 .001-.653l-.196-.196a.453.453 0 0 0-.03-.033l-3.479-3.479a.464.464 0 0 0-.654.007l-.548.548a.463.463 0 0 0-.007.654l2.886 2.886z"/><path d="M10.08 11.063l-2.819 2.82a.46.46 0 0 0-.002.652l.496.495a.457.457 0 0 0 .652-.001l3.7-3.7a.46.46 0 0 0 .002-.653l-.196-.196a.453.453 0 0 0-.03-.033l-3.48-3.479a.464.464 0 0 0-.653.007l-.548.548a.463.463 0 0 0-.007.654l2.886 2.886z"/></svg>
diff --git a/app/views/shared/icons/_mr_widget_empty_state.svg b/app/views/shared/icons/_mr_widget_empty_state.svg
new file mode 100644
index 00000000000..6a811893b2d
--- /dev/null
+++ b/app/views/shared/icons/_mr_widget_empty_state.svg
@@ -0,0 +1 @@
+<svg width="256" height="146" viewBox="0 0 256 146" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>illustration</title><defs><rect id="a" width="178.714" height="115.389" rx="10"/><mask id="d" x="0" y="0" width="178.714" height="115.389" fill="#fff"><use xlink:href="#a"/></mask><path d="M8.796 31.515c.395.047.8.072 1.207.072h23.065c5.536 0 10.003-4.475 10.003-9.994v-11.6C43.07 4.476 38.594 0 33.07 0H10.003C4.467 0 0 4.475 0 9.994v11.6c0 1.248.23 2.444.65 3.547H0v7.414c0 4.094 2.394 5.113 5.342 2.28l3.454-3.32z" id="b"/><mask id="e" x="0" y="0" width="43.071" height="36.437" fill="#fff"><use xlink:href="#b"/></mask><path d="M8.796 31.515c.395.047.8.072 1.207.072h23.065c5.536 0 10.003-4.475 10.003-9.994v-11.6C43.07 4.476 38.594 0 33.07 0H10.003C4.467 0 0 4.475 0 9.994v11.6c0 1.248.23 2.444.65 3.547H0v7.414c0 4.094 2.394 5.113 5.342 2.28l3.454-3.32z" id="c"/><mask id="f" x="0" y="0" width="43.071" height="36.437" fill="#fff"><use xlink:href="#c"/></mask></defs><g fill="none" fill-rule="evenodd"><g transform="translate(0 3.868)" fill="#F9F9F9"><rect x="19.286" width="77.143" height="14.182" rx="7.091"/><rect y="28.364" width="84.857" height="14.182" rx="7.091"/><rect x="133.714" y="42.546" width="122.143" height="14.182" rx="7.091"/><rect x="82.929" y="126.992" width="101.571" height="14.182" rx="7.091"/><rect x="42.429" y="99.273" width="101.571" height="14.182" rx="7.091"/><rect x="19.929" y="70.909" width="225" height="14.182" rx="7.091"/><path d="M98.37 14.182H13.488h13.81a7.098 7.098 0 0 1 7.094 7.09 7.09 7.09 0 0 1-7.094 7.092h-13.81 84.88-23.452a7.098 7.098 0 0 1-7.095-7.09 7.09 7.09 0 0 1 7.096-7.092h23.452zm162 42.545h-75.238 23.452a7.098 7.098 0 0 1 7.095 7.09 7.09 7.09 0 0 1-7.096 7.092h-23.452 75.237-23.453a7.098 7.098 0 0 1-7.095-7.09 7.09 7.09 0 0 1 7.095-7.093h23.452zM103.512 85.09H28.275h23.452a7.098 7.098 0 0 1 7.095 7.092 7.09 7.09 0 0 1-7.095 7.09H28.275h75.237H80.06a7.098 7.098 0 0 1-7.095-7.09 7.09 7.09 0 0 1 7.095-7.09h23.452zm48.215 28.365H76.49 90.3a7.098 7.098 0 0 1 7.093 7.09 7.09 7.09 0 0 1-7.094 7.092H76.49h75.237-33.096a7.098 7.098 0 0 1-7.094-7.09 7.09 7.09 0 0 1 7.095-7.092h33.097z"/></g><g transform="translate(38.57 12.248)"><use stroke="#EEE" mask="url(#d)" stroke-width="8" fill="#FFF" xlink:href="#a"/><path fill="#EEE" d="M2.57 18.694h174.215v2.58H2.57z"/><g transform="translate(21.857 38.678)"><rect fill="#B5A7DD" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#B5A7DD" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#B5A7DD" y="14.826" width="3.857" height="1.289" rx=".645"/></g><g transform="translate(21.857 59.95)"><rect fill="#B5A7DD" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#FC6D26" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#B5A7DD" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#B5A7DD" y="14.826" width="3.857" height="1.289" rx=".645"/></g><g transform="translate(21.857 81.223)"><rect fill="#B5A7DD" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#B5A7DD" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#B5A7DD" y="14.826" width="3.857" height="1.289" rx=".645"/></g><g transform="translate(100.93 38.033)"><rect fill="#FDE5D8" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#FDE5D8" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="14.826" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="21.917" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" y="21.273" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="37.286" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="25.071" y="35.455" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="18.643" y="28.364" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="30.857" y="21.273" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="35.455" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="21.857" y="21.273" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="28.364" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="30.857" y="28.364" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="39.857" y="28.364" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="49.5" y="14.182" width="6.429" height="2.579" rx="1.289"/><rect fill="#FDE5D8" y="29.008" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="36.099" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="43.19" width="3.857" height="1.289" rx=".645"/><rect fill="#6B4FBB" x="9.643" y="42.546" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="25.071" y="56.727" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="34.071" y="49.636" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="56.727" width="12.857" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="18.643" y="49.636" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="21.857" y="42.546" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" y="49.636" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="49.636" width="6.429" height="2.579" rx="1.289"/><rect fill="#FDE5D8" y="50.281" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="57.372" width="3.857" height="1.289" rx=".645"/></g></g><g transform="translate(196.07)"><use stroke="#FDE5D8" mask="url(#e)" stroke-width="8" fill="#FFF" xlink:href="#b"/><rect fill="#FDB692" x="9" y="9.025" width="18.643" height="1.934" rx=".967"/><rect fill="#FDB692" x="9" y="14.826" width="25.071" height="1.934" rx=".967"/><rect fill="#FDB692" x="9" y="20.628" width="18.643" height="1.934" rx=".967"/></g><g transform="translate(189 41.256)"><ellipse stroke="#FC6D26" stroke-width="3" fill="#FFF7F4" cx="10.286" cy="9.669" rx="9.643" ry="9.669"/><path d="M.023 9.002a8.352 8.352 0 0 0 7.94-4.308M9 .644c0-.21-.008-.416-.023-.62" stroke="#FC6D26" stroke-width="2"/><path d="M5.045 2.008A10.266 10.266 0 0 0 13.5 6.446c2.112 0 4.076-.638 5.71-1.733" stroke="#FC6D26" stroke-width="2"/><ellipse fill="#FC6D26" cx="6.75" cy="11.281" rx=".964" ry=".967"/><ellipse fill="#FC6D26" cx="13.821" cy="11.281" rx=".964" ry=".967"/></g><g transform="translate(46.93 96.05)"><ellipse stroke="#6B4FBB" stroke-width="3" fill="#F4F1FA" cx="9.643" cy="10.314" rx="9.643" ry="9.669"/><path d="M12.86 4.51h-.005L11.25 2.58 9.645 4.51H9.64L8.036 2.58 6.43 4.51h-.002L4.82 2.58 3.215 4.512h-1.75A9.646 9.646 0 0 1 9.642 0c3.447 0 6.47 1.8 8.176 4.508h-1.75l-1.605-1.93L12.86 4.51z" fill="#6B4FBB"/><ellipse fill="#6B4FBB" cx="6.107" cy="11.281" rx=".964" ry=".967"/><ellipse fill="#6B4FBB" cx="13.179" cy="11.281" rx=".964" ry=".967"/></g><g transform="matrix(-1 0 0 1 56.57 54.794)"><use stroke="#E2DCF2" mask="url(#f)" stroke-width="8" fill="#FFF" xlink:href="#c"/><rect fill="#6B4FBB" opacity=".5" x="15.429" y="9.025" width="18.643" height="1.934" rx=".967"/><rect fill="#6B4FBB" opacity=".5" x="21.857" y="14.826" width="12.214" height="1.934" rx=".967"/><rect fill="#6B4FBB" opacity=".5" x="21.857" y="20.628" width="12.214" height="1.934" rx=".967"/></g></g></svg>
diff --git a/app/views/shared/icons/_scroll_down.svg b/app/views/shared/icons/_scroll_down.svg
index acf22ac9314..1d22870ec09 100644
--- a/app/views/shared/icons/_scroll_down.svg
+++ b/app/views/shared/icons/_scroll_down.svg
@@ -1,3 +1,5 @@
-<svg width="16" height="33" class="gitlab-icon-scroll-down" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg">
- <path fill="#ffffff" d="M1.385 5.534v12.47a4.145 4.145 0 0 0 4.144 4.15h4.942a4.151 4.151 0 0 0 4.144-4.15V5.535a4.145 4.145 0 0 0-4.144-4.15H5.53a4.151 4.151 0 0 0-4.144 4.15zM8.88 30.27v-4.351a.688.688 0 0 0-.69-.688.687.687 0 0 0-.69.688v4.334l-1.345-1.346a.69.69 0 0 0-.976.976l2.526 2.526a.685.685 0 0 0 .494.2.685.685 0 0 0 .493-.2l2.526-2.526a.69.69 0 1 0-.976-.976L8.88 30.27zM0 5.534A5.536 5.536 0 0 1 5.529 0h4.942A5.53 5.53 0 0 1 16 5.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 18.005V5.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0V6.544z" fill-rule="evenodd"/>
+<svg width="12" height="16" viewBox="0 0 12 16" xmlns="http://www.w3.org/2000/svg">
+ <path class="first-triangle" d="M1.048 14.155a.508.508 0 0 0-.32.105c-.091.07-.136.154-.136.25v.71c0 .095.045.178.135.249.09.07.197.105.321.105h10.043c.124 0 .23-.035.321-.105.09-.07.136-.154.136-.25v-.71c0-.095-.045-.178-.136-.249a.508.508 0 0 0-.32-.105"/>
+ <path class="second-triangle" d="M.687 8.027c-.09-.087-.122-.16-.093-.22.028-.06.104-.09.228-.09h10.5c.123 0 .2.03.228.09.029.06-.002.133-.093.22L6.393 12.91a.458.458 0 0 1-.136.089h-.37a.626.626 0 0 1-.136-.09"/>
+ <path class="third-triangle" d="M.687 1.027C.597.94.565.867.594.807c.028-.06.104-.09.228-.09h10.5c.123 0 .2.03.228.09.029.06-.002.133-.093.22L6.393 5.91A.458.458 0 0 1 6.257 6h-.37a.626.626 0 0 1-.136-.09"/>
</svg>
diff --git a/app/views/shared/icons/_scroll_down_hover_active.svg b/app/views/shared/icons/_scroll_down_hover_active.svg
deleted file mode 100644
index 262576acf54..00000000000
--- a/app/views/shared/icons/_scroll_down_hover_active.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-<svg width="16" height="33" class="gitlab-icon-scroll-down-hover" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg">
- <path fill="#ffffff" d="M8.88 30.27v-4.351a.688.688 0 0 0-.69-.688.687.687 0 0 0-.69.688v4.334l-1.345-1.346a.69.69 0 0 0-.976.976l2.526 2.526a.685.685 0 0 0 .494.2.685.685 0 0 0 .493-.2l2.526-2.526a.69.69 0 1 0-.976-.976L8.88 30.27zM0 5.534A5.536 5.536 0 0 1 5.529 0h4.942A5.53 5.53 0 0 1 16 5.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 18.005V5.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0V6.544z" fill-rule="evenodd"/>
-</svg>
diff --git a/app/views/shared/icons/_scroll_up.svg b/app/views/shared/icons/_scroll_up.svg
index f11288fd59c..70b1e4d9c91 100644
--- a/app/views/shared/icons/_scroll_up.svg
+++ b/app/views/shared/icons/_scroll_up.svg
@@ -1,3 +1 @@
-<svg width="16" height="33" class="gitlab-icon-scroll-up" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg">
- <path fill="#ffffff" d="M1.385 14.534v12.47a4.145 4.145 0 0 0 4.144 4.15h4.942a4.151 4.151 0 0 0 4.144-4.15v-12.47a4.145 4.145 0 0 0-4.144-4.15H5.53a4.151 4.151 0 0 0-4.144 4.15zM8.88 2.609V6.96a.688.688 0 0 1-.69.688.687.687 0 0 1-.69-.688V2.627L6.155 3.972a.69.69 0 0 1-.976-.976L7.705.47a.685.685 0 0 1 .494-.2.685.685 0 0 1 .493.2l2.526 2.526a.69.69 0 1 1-.976.976L8.88 2.609zM0 14.534A5.536 5.536 0 0 1 5.529 9h4.942A5.53 5.53 0 0 1 16 14.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 27.005V14.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0v-2.143z" fill-rule="evenodd"/>
-</svg>
+<svg width="12" height="16" viewBox="0 0 12 16" xmlns="http://www.w3.org/2000/svg"><path d="M1.048 1.845a.508.508 0 0 1-.32-.105c-.091-.07-.136-.154-.136-.25V.78c0-.095.045-.178.135-.249a.508.508 0 0 1 .321-.105h10.043c.124 0 .23.035.321.105.09.07.136.154.136.25v.71c0 .095-.045.178-.136.249a.508.508 0 0 1-.32.105"/><path d="M.687 7.973c-.09.087-.122.16-.093.22.028.06.104.09.228.09h10.5c.123 0 .2-.03.228-.09.029-.06-.002-.133-.093-.22L6.393 3.09A.458.458 0 0 0 6.257 3h-.37a.626.626 0 0 0-.136.09"/><path d="M.687 14.973c-.09.087-.122.16-.093.22.028.06.104.09.228.09h10.5c.123 0 .2-.03.228-.09.029-.06-.002-.133-.093-.22L6.393 10.09A.458.458 0 0 0 6.257 10h-.37a.626.626 0 0 0-.136.09"/></svg>
diff --git a/app/views/shared/icons/_scroll_up_hover_active.svg b/app/views/shared/icons/_scroll_up_hover_active.svg
deleted file mode 100644
index 4658dbb1bb7..00000000000
--- a/app/views/shared/icons/_scroll_up_hover_active.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-<svg width="16" height="33" class="gitlab-icon-scroll-up-hover" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg">
- <path fill="#ffffff" d="M8.88 2.646l1.362 1.362a.69.69 0 0 0 .976-.976L8.692.507A.685.685 0 0 0 8.2.306a.685.685 0 0 0-.494.2L5.179 3.033a.69.69 0 1 0 .976.976L7.5 2.663v4.179c0 .38.306.688.69.688.381 0 .69-.306.69-.688V2.646zM0 14.534A5.536 5.536 0 0 1 5.529 9h4.942A5.53 5.53 0 0 1 16 14.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 27.005V14.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0v-2.143z" fill-rule="evenodd"/>
-</svg>
diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
new file mode 100644
index 00000000000..a8a6d84128d
--- /dev/null
+++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
@@ -0,0 +1,53 @@
+- type = local_assigns.fetch(:type)
+
+%aside.issues-bulk-update.js-right-sidebar.right-sidebar.affix-top{ data: { "offset-top" => "50", "spy" => "affix" }, "aria-live" => "polite" }
+ .issuable-sidebar
+ = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: "bulk-update" do
+ .block
+ .filter-item.inline.update-issues-btn.pull-left
+ = button_tag "Update all", class: "btn update-selected-issues btn-info", disabled: true
+ = button_tag "Cancel", class: "btn btn-default js-bulk-update-menu-hide pull-right"
+ .block
+ .title
+ Status
+ .filter-item
+ = dropdown_tag("Select 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
+ .block
+ .title
+ Assignee
+ .filter-item
+ - if type == :issues
+ - field_name = "update[assignee_ids][]"
+ - else
+ - field_name = "update[assignee_id]"
+ = dropdown_tag("Select 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: field_name } })
+ .block
+ .title
+ Milestone
+ .filter-item
+ = dropdown_tag("Select 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" } })
+ .block
+ .title
+ Labels
+ .filter-item.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, default_label: "Labels" }, label_name: "Select labels", no_default_styles: true
+ .block
+ .title
+ Subscriptions
+ .filter-item
+ = dropdown_tag("Select 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
+ %li
+ %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe
+
+ = hidden_field_tag "update[issuable_ids]", []
+ = hidden_field_tag :state_event, params[:state_event]
+
diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml
index 1a12f110945..2cabbc8c560 100644
--- a/app/views/shared/issuable/_filter.html.haml
+++ b/app/views/shared/issuable/_filter.html.haml
@@ -6,10 +6,6 @@
= form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do
- if params[:search].present?
= hidden_field_tag :search, params[:search]
- - if @bulk_edit
- .check-all-holder
- = check_box_tag "check_all_issues", nil, false,
- class: "check_all_issues left"
.issues-other-filters
.filter-item.inline
- if params[:author_id].present?
@@ -36,42 +32,12 @@
.pull-right
= render 'shared/sort_dropdown'
- - if @bulk_edit
- .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: "issue-bulk-update-dropdown-toggle 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
- = dropdown_tag("Assignee", options: { toggle_class: "issue-bulk-update-dropdown-toggle 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]", default_label: "Assignee" } })
- .filter-item.inline
- = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'issue-bulk-update-dropdown-toggle 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]", default_label: "Milestone", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } })
- .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 }
- .filter-item.inline
- = dropdown_tag("Subscription", options: { toggle_class: "issue-bulk-update-dropdown-toggle 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
- %li
- %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe
-
- = hidden_field_tag 'update[issuable_ids]', []
- = hidden_field_tag :state_event, params[:state_event]
- .filter-item.inline
- = button_tag "Update #{type.to_s.humanize(capitalize: false)}", class: "btn update_selected_issues btn-save"
- has_labels = @labels && @labels.any?
.row-content-block.second-block.filtered-labels{ class: ("hidden" unless has_labels) }
- if has_labels
= render 'shared/labels_row', labels: @labels
:javascript
- new UsersSelect();
new LabelsSelect();
new MilestoneSelect();
new IssueStatusSelect();
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 7748351b333..c016aa2abcd 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, project: project
+= render 'shared/form_elements/description', model: issuable, form: form, project: project
- if issuable.respond_to?(:confidential)
.form-group
diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml
index 93c7fa0c7d6..34911fd2712 100644
--- a/app/views/shared/issuable/_label_dropdown.html.haml
+++ b/app/views/shared/issuable/_label_dropdown.html.haml
@@ -9,8 +9,10 @@
- selected = local_assigns.fetch(:selected, nil)
- selected_toggle = local_assigns.fetch(:selected_toggle, nil)
- dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by label")
-- dropdown_data = {toggle: 'dropdown', field_name: "label_name[]", show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path), labels: labels_filter_path, default_label: "Labels"}
+- dropdown_data = {toggle: 'dropdown', field_name: "label_name[]", show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), labels: labels_filter_path, default_label: "Labels"}
- dropdown_data.merge!(data_options)
+- label_name = local_assigns.fetch(:label_name, "Labels")
+- no_default_styles = local_assigns.fetch(:no_default_styles, false)
- classes << 'js-extra-options' if extra_options
- classes << 'js-filter-submit' if filter_submit
@@ -20,8 +22,9 @@
.dropdown
%button.dropdown-menu-toggle.js-label-select.js-multiselect{ class: classes.join(' '), type: "button", data: dropdown_data }
- %span.dropdown-toggle-text{ class: ("is-default" if selected.nil? || selected.empty?) }
- = multi_label_name(selected, "Labels")
+ - apply_is_default_styles = (selected.nil? || selected.empty?) && !no_default_styles
+ %span.dropdown-toggle-text{ class: ("is-default" if apply_is_default_styles) }
+ = multi_label_name(selected, label_name)
= icon('chevron-down')
.dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
= render partial: "shared/issuable/label_page_default", locals: { title: dropdown_title, show_footer: show_footer, show_create: show_create }
diff --git a/app/views/shared/issuable/_milestone_dropdown.html.haml b/app/views/shared/issuable/_milestone_dropdown.html.haml
index f0d50828e2a..6750921338a 100644
--- a/app/views/shared/issuable/_milestone_dropdown.html.haml
+++ b/app/views/shared/issuable/_milestone_dropdown.html.haml
@@ -6,7 +6,7 @@
- if selected.present? || params[:milestone_title].present?
= hidden_field_tag(name, name == :milestone_title ? selected_text : selected.id)
= dropdown_tag(milestone_dropdown_label(selected_text), options: { title: dropdown_title, toggle_class: "js-milestone-select js-filter-submit #{extra_class}", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone",
- placeholder: "Search milestones", footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected.try(:title), project_id: project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do
+ placeholder: "Search milestones", footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected_text, project_id: project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do
- if project
%ul.dropdown-footer-list
- if can? current_user, :admin_milestone, project
diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml
index ad995cbe962..cf7ba52d840 100644
--- a/app/views/shared/issuable/_nav.html.haml
+++ b/app/views/shared/issuable/_nav.html.haml
@@ -1,25 +1,24 @@
- type = local_assigns.fetch(:type, :issues)
- page_context_word = type.to_s.humanize(capitalize: false)
- issuables = @issues || @merge_requests
+- closed_title = 'Filter by issues that are currently closed.'
%ul.nav-links.issues-state-filters
%li{ class: active_when(params[:state] == 'opened') }>
- = link_to page_filter_path(state: 'opened', label: true), id: 'state-opened', title: "Filter by #{page_context_word} that are currently opened." do
+ %button.btn.btn-link{ id: 'state-opened', title: "Filter by #{page_context_word} that are currently opened.", type: 'button', data: { state: 'opened' } }
#{issuables_state_counter_text(type, :opened)}
- if type == :merge_requests
%li{ class: active_when(params[:state] == 'merged') }>
- = link_to page_filter_path(state: 'merged', label: true), id: 'state-merged', title: 'Filter by merge requests that are currently merged.' do
+ %button.btn.btn-link{ id: 'state-merged', title: 'Filter by merge requests that are currently merged.', type: 'button', data: { state: 'merged' } }
#{issuables_state_counter_text(type, :merged)}
- %li{ class: active_when(params[:state] == 'closed') }>
- = link_to page_filter_path(state: 'closed', label: true), id: 'state-closed', title: 'Filter by merge requests that are currently closed and unmerged.' do
- #{issuables_state_counter_text(type, :closed)}
- - else
- %li{ class: active_when(params[:state] == 'closed') }>
- = link_to page_filter_path(state: 'closed', label: true), id: 'state-all', title: 'Filter by issues that are currently closed.' do
- #{issuables_state_counter_text(type, :closed)}
+ - closed_title = 'Filter by merge requests that are currently closed and unmerged.'
+
+ %li{ class: active_when(params[:state] == 'closed') }>
+ %button.btn.btn-link{ id: 'state-closed', title: closed_title, type: 'button', data: { state: 'closed' } }
+ #{issuables_state_counter_text(type, :closed)}
%li{ class: active_when(params[:state] == 'all') }>
- = link_to page_filter_path(state: 'all', label: true), id: 'state-all', title: "Show all #{page_context_word}." do
+ %button.btn.btn-link{ id: 'state-all', title: "Show all #{page_context_word}.", type: 'button', data: { state: 'all' } }
#{issuables_state_counter_text(type, :all)}
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index f7b87171573..d3d290692a2 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -6,28 +6,25 @@
= form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do
- if params[:search].present?
= hidden_field_tag :search, params[:search]
- - if @bulk_edit
- .check-all-holder
- = check_box_tag "check_all_issues", nil, false,
- class: "check_all_issues left"
+ - if @can_bulk_update
+ .check-all-holder.hidden
+ = check_box_tag "check-all-issues", nil, false, class: "check-all-issues left"
.issues-other-filters.filtered-search-wrapper
.filtered-search-box
- if type != :boards_modal && type != :boards
- = dropdown_tag(content_tag(:i, '', class: 'fa fa-history'),
+ = dropdown_tag(custom_icon('icon_history'),
options: { wrapper_class: "filtered-search-history-dropdown-wrapper",
toggle_class: "filtered-search-history-dropdown-toggle-button",
dropdown_class: "filtered-search-history-dropdown",
content_class: "filtered-search-history-dropdown-content",
title: "Recent searches" }) do
- .js-filtered-search-history-dropdown
+ .js-filtered-search-history-dropdown{ data: { project_full_path: @project.full_path } }
.filtered-search-box-input-container
.scroll-container
%ul.tokens-container.list-unstyled
%li.input-token
%input.form-control.filtered-search{ id: "filtered-search-#{type.to_s}", placeholder: 'Search or filter results...', data: { 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } }
= icon('filter')
- %button.clear-search.hidden{ type: 'button' }
- = icon('times')
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { action: 'submit' } }
@@ -45,32 +42,29 @@
{{hint}}
%span.js-filter-tag.dropdown-light-content
{{tag}}
- #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'pencil', hint: 'author', tag: '@author' } }
+ #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu
+ - if current_user
+ %ul{ data: { dropdown: true } }
+ = render 'shared/issuable/user_dropdown_item',
+ user: current_user
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.btn.btn-link.dropdown-user
- %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } }
- .dropdown-user-details
- %span
- {{name}}
- %span.dropdown-light-content
- @{{username}}
- #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'user', hint: 'assignee', tag: '@assignee' } }
+ = render 'shared/issuable/user_dropdown_item',
+ user: User.new(username: '{{username}}', name: '{{name}}'),
+ avatar: { lazy: true, url: '{{avatar_url}}' }
+ #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link
No Assignee
%li.divider
+ - if current_user
+ = render 'shared/issuable/user_dropdown_item',
+ user: current_user
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.btn.btn-link.dropdown-user
- %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } }
- .dropdown-user-details
- %span
- {{name}}
- %span.dropdown-light-content
- @{{username}}
- #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'clock-o', hint: 'milestone', tag: '%milestone' } }
+ = render 'shared/issuable/user_dropdown_item',
+ user: User.new(username: '{{username}}', name: '{{name}}'),
+ avatar: { lazy: true, url: '{{avatar_url}}' }
+ #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link
@@ -86,7 +80,7 @@
%li.filter-dropdown-item
%button.btn.btn-link.js-data-value
{{title}}
- #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } }
+ #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link
@@ -98,6 +92,8 @@
%span.dropdown-label-box{ style: 'background: {{color}}' }
%span.label-title.js-data-value
{{title}}
+ %button.clear-search.hidden{ type: 'button' }
+ = icon('times')
.filter-dropdown-container
- if type == :boards
- if can?(current_user, :admin_list, @project)
@@ -113,55 +109,11 @@
- elsif type != :boards_modal
= render 'shared/sort_dropdown'
- - if @bulk_edit
- .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]", 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: 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, 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, 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]", default_label: "Subscription" } } ) do
- %ul
- %li
- %a{ href: "#", data: { id: "subscribe" } } Subscribe
- %li
- %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe
-
- = hidden_field_tag 'update[issuable_ids]', []
- = hidden_field_tag :state_event, params[:state_event]
- .filter-item.inline.update-issues-btn
- = button_tag "Update #{type.to_s.humanize(capitalize: false)}", class: "btn update_selected_issues btn-save"
-
- unless type === :boards_modal
:javascript
- new UsersSelect();
- new LabelsSelect();
- new MilestoneSelect();
- new IssueStatusSelect();
- new SubscriptionSelect();
-
$(document).off('page:restore').on('page:restore', function (event) {
if (gl.FilteredSearchManager) {
- new gl.FilteredSearchManager();
+ const filteredSearchManager = new gl.FilteredSearchManager();
+ filteredSearchManager.setup();
}
- Issuable.init();
- new gl.IssuableBulkActions({
- prefixId: 'issue_',
- });
});
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 418e1b2d73f..e49bd5ebb13 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -3,7 +3,7 @@
= page_specific_javascript_bundle_tag('common_vue')
= 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' }
+%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "50", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
.issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } }
- can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
.block.issuable-sidebar-header
@@ -43,7 +43,7 @@
.selectbox.hide-collapsed
= f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil
- = 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 }})
+ = 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, default_no: true, selected: (issuable.milestone.name if issuable.milestone), null_default: true }})
- if issuable.has_attribute?(:time_estimate)
#issuable-time-tracker.block
// Fallback while content is loading
diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml
index 26567c08eb6..bcfa1dc826e 100644
--- a/app/views/shared/issuable/_sidebar_assignees.html.haml
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -32,7 +32,7 @@
.selectbox.hide-collapsed
- issuable.assignees.each do |assignee|
- = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil
+ = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username }
- 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 } }
diff --git a/app/views/shared/issuable/_user_dropdown_item.html.haml b/app/views/shared/issuable/_user_dropdown_item.html.haml
new file mode 100644
index 00000000000..a82c01c6dc2
--- /dev/null
+++ b/app/views/shared/issuable/_user_dropdown_item.html.haml
@@ -0,0 +1,11 @@
+- user = local_assigns.fetch(:user)
+- avatar = local_assigns.fetch(:avatar, { })
+
+%li.filter-dropdown-item{ class: ('js-current-user' if user == current_user) }
+ %button.btn.btn-link.dropdown-user{ type: :button }
+ = user_avatar_without_link(user: user, lazy: avatar[:lazy], url: avatar[:url], size: 30)
+ .dropdown-user-details
+ %span
+ = user.name
+ %span.dropdown-light-content
+ = user.to_reference
diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml
index f57b4d899ce..203d2adc8db 100644
--- a/app/views/shared/issuable/form/_branch_chooser.html.haml
+++ b/app/views/shared/issuable/form/_branch_chooser.html.haml
@@ -10,14 +10,14 @@
= 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', disabled: true })
+ = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2 ref-name', disabled: true })
.form-group
= form.label :target_branch, class: 'control-label'
.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 js-target-branch-select',
+ { class: 'target_branch js-target-branch-select ref-name',
disabled: issuable.new_record?,
data: { placeholder: "Select branch" }})
- if issuable.new_record?
diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml
index d23f79be2be..271150ed318 100644
--- a/app/views/shared/issuable/form/_merge_params.html.haml
+++ b/app/views/shared/issuable/form/_merge_params.html.haml
@@ -5,3 +5,13 @@
-# This check is duplicated below, to avoid conflicts with EE.
- return unless issuable.can_remove_source_branch?(current_user)
+
+.form-group
+ .col-sm-10.col-sm-offset-2
+ - if issuable.can_remove_source_branch?(current_user)
+ .checkbox
+ - initial_checkbox_value = issuable.merge_params.key?('force_remove_source_branch') ? issuable.force_remove_source_branch? : true
+ = label_tag 'merge_request[force_remove_source_branch]' do
+ = hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil
+ = check_box_tag 'merge_request[force_remove_source_branch]', '1', initial_checkbox_value
+ Remove source branch when merge request is accepted.
diff --git a/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml
index 8119f19291b..77175c839a6 100644
--- a/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml
+++ b/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml
@@ -2,7 +2,7 @@
.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 }
+ = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name, avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username }
- if issuable.assignees.length === 0
= hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' }
diff --git a/app/views/shared/members/_access_request_buttons.html.haml b/app/views/shared/members/_access_request_buttons.html.haml
index fb795ad1c72..d97fdf179d7 100644
--- a/app/views/shared/members/_access_request_buttons.html.haml
+++ b/app/views/shared/members/_access_request_buttons.html.haml
@@ -2,16 +2,17 @@
.project-action-button.inline
- if can?(current_user, :"destroy_#{model_name}_member", source.members.find_by(user_id: current_user.id))
- = link_to "Leave #{model_name}", polymorphic_path([:leave, source, :members]),
+ - link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project')
+ = link_to link_text, polymorphic_path([:leave, source, :members]),
method: :delete,
data: { confirm: leave_confirmation_message(source) },
class: 'btn'
- elsif requester = source.requesters.find_by(user_id: current_user.id)
- = link_to 'Withdraw Access Request', polymorphic_path([:leave, source, :members]),
+ = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]),
method: :delete,
data: { confirm: remove_member_message(requester) },
class: 'btn'
- elsif source.request_access_enabled && can?(current_user, :request_access, source)
- = link_to 'Request Access', polymorphic_path([:request_access, source, :members]),
+ = link_to _('Request Access'), polymorphic_path([:request_access, source, :members]),
method: :post,
class: 'btn'
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index 5e8a2a0f5d8..9bb87640319 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -1,4 +1,4 @@
-- affix_offset = local_assigns.fetch(:affix_offset, "102")
+- affix_offset = local_assigns.fetch(:affix_offset, "50")
- project = local_assigns[:project]
%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml
index 81d97eabe65..7ce6130de60 100644
--- a/app/views/shared/notes/_hints.html.haml
+++ b/app/views/shared/notes/_hints.html.haml
@@ -9,6 +9,27 @@
- else
is
supported
- %button.toolbar-button.markdown-selector{ type: 'button', tabindex: '-1' }
- = icon('file-image-o', class: 'toolbar-button-icon')
- Attach a file
+
+ %span.uploading-container
+ %span.uploading-progress-container.hide
+ = icon('file-image-o', class: 'toolbar-button-icon')
+ %span.attaching-file-message
+ -# Populated by app/assets/javascripts/dropzone_input.js
+ %span.uploading-progress 0%
+ %span.uploading-spinner
+ = icon('spinner spin', class: 'toolbar-button-icon')
+
+ %span.uploading-error-container.hide
+ %span.uploading-error-icon
+ = icon('file-image-o', class: 'toolbar-button-icon')
+ %span.uploading-error-message
+ -# Populated by app/assets/javascripts/dropzone_input.js
+ %button.retry-uploading-link{ type: 'button' } Try again
+ or
+ %button.attach-new-file.markdown-selector{ type: 'button' } attach a new file
+
+ %button.markdown-selector.button-attach-file{ type: 'button', tabindex: '-1' }
+ = icon('file-image-o', class: 'toolbar-button-icon')
+ Attach a file
+
+ %button.btn.btn-default.btn-xs.hide.button-cancel-uploading-files{ type: 'button' } Cancel
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
index a7bf610b9c7..1e34b7c1e76 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -18,7 +18,7 @@
.note-header
.note-header-info
%a{ href: user_path(note.author) }
- %span.hidden-xs
+ %span.note-header-author-name
= sanitize(note.author.name)
%span.note-headline-light
= note.author.to_reference
diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml
index 9930cbd96d7..5902798dfd0 100644
--- a/app/views/shared/notes/_notes_with_form.html.haml
+++ b/app/views/shared/notes/_notes_with_form.html.haml
@@ -3,24 +3,23 @@
= render 'shared/notes/edit_form', project: @project
-%ul.notes.notes-form.timeline
- %li.timeline-entry
- .flash-container.timeline-content
+- if can_create_note?
+ %ul.notes.notes-form.timeline
+ %li.timeline-entry
+ .flash-container.timeline-content
- - 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 "shared/notes/form", view: diff_view
- - elsif !current_user
- .disabled-comment.text-center
- .disabled-comment-text.inline
- Please
- = link_to "register", new_session_path(:user, redirect_to_referer: 'yes')
- or
- = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes')
- to post a comment
+- elsif !current_user
+ .disabled-comment.text-center.prepend-top-default
+ Please
+ = link_to "register", new_session_path(:user, redirect_to_referer: 'yes')
+ or
+ = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes')
+ to comment
:javascript
- var notes = new Notes("#{notes_url}", #{@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}", #{autocomplete})
diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml
index 1d072c16b32..e99d8d0973f 100644
--- a/app/views/shared/notifications/_button.html.haml
+++ b/app/views/shared/notifications/_button.html.haml
@@ -6,14 +6,14 @@
.js-notification-toggle-btns
%div{ class: ("btn-group" if notification_setting.custom?) }
- if notification_setting.custom?
- %button.dropdown-new.btn.btn-default.notifications-btn#notifications-button{ type: "button", data: { toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting) } }
+ %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting) } }
= icon("bell", class: "js-notification-loading")
= notification_title(notification_setting.level)
%button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting) } }
= icon('caret-down')
.sr-only Toggle dropdown
- else
- %button.dropdown-new.btn.btn-default.notifications-btn#notifications-button{ type: "button", data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting) } }
+ %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting) } }
= icon("bell", class: "js-notification-loading")
= notification_title(notification_setting.level)
= icon("caret-down")
diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml
index 708adbc38f1..752932e6045 100644
--- a/app/views/shared/notifications/_custom_notifications.html.haml
+++ b/app/views/shared/notifications/_custom_notifications.html.haml
@@ -1,11 +1,11 @@
-.modal.fade{ tabindex: "-1", role: "dialog", id: notifications_menu_identifier("modal", notification_setting), aria: { labelledby: "custom-notifications-title" } }
+.modal.fade{ tabindex: "-1", role: "dialog", id: notifications_menu_identifier("modal", notification_setting), "aria-labelledby": "custom-notifications-title" }
.modal-dialog
.modal-content
.modal-header
- %button.close{ type: "button", data: { dismiss: "modal" }, aria: { label: "close" } }
- %span{ aria: { hidden: "true" } } ×
+ %button.close{ type: "button", "aria-label": "close", data: { dismiss: "modal" } }
+ %span{ "aria-hidden": "true" } } ×
%h4#custom-notifications-title.modal-title
- Custom notification events
+ #{ _('Custom notification events') }
.modal-body
.container-fluid
@@ -13,12 +13,11 @@
= hidden_setting_source_input(notification_setting)
.row
.col-lg-4
- %h4.prepend-top-0
- Notification events
+ %h4.prepend-top-0= _('Notification events')
%p
- Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out
- = succeed "." do
- %a{ href: help_page_path('workflow/notifications'), target: "_blank" } notification emails
+ - notification_link = link_to _('notification emails'), help_page_path('workflow/notifications'), target: '_blank'
+ - paragraph = _('Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.') % { notification_link: notification_link.html_safe }
+ #{ paragraph.html_safe }
.col-lg-8
- NotificationSetting::EMAIL_EVENTS.each_with_index do |event, index|
- field_id = "#{notifications_menu_identifier("modal", notification_setting)}_notification_setting[#{event}]"
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index cf0540afb38..fbc335f6176 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -7,7 +7,7 @@
- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && project.commit
- css_class += " no-description" if project.description.blank? && !show_last_commit_as_description
- cache_key = project_list_cache_key(project)
-- updated_tooltip = time_ago_with_tooltip(project.updated_at)
+- updated_tooltip = time_ago_with_tooltip(project.last_activity_at)
%li.project-row{ class: css_class }
= cache(cache_key) do
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 0296597b294..8549cb91b03 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -3,7 +3,7 @@
= page_specific_javascript_bundle_tag('snippet')
.snippet-form-holder
- = form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input js-quick-submit" } do |f|
+ = form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input js-quick-submit common-note-form" } do |f|
= form_errors(@snippet)
.form-group
@@ -11,6 +11,8 @@
.col-sm-10
= f.text_field :title, class: 'form-control', required: true, autofocus: true
+ = render 'shared/form_elements/description', model: @snippet, project: @project, form: f
+
= render 'shared/visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet
.file-editor
@@ -23,6 +25,9 @@
.file-content.code
%pre#editor= @snippet.content
= f.hidden_field :content, class: 'snippet-file-content'
+ - if params[:files]
+ - params[:files].each_with_index do |file, index|
+ = hidden_field_tag "files[]", file, id: "files_#{index}"
.form-actions
- if @snippet.new_record?
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index 501c09d71d5..813d8d69d8d 100644
--- a/app/views/shared/snippets/_header.html.haml
+++ b/app/views/shared/snippets/_header.html.haml
@@ -22,3 +22,9 @@
- if @snippet.updated_at != @snippet.created_at
= edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true)
+ - if @snippet.description.present?
+ .description
+ .wiki
+ = markdown_field(@snippet, :description)
+ %textarea.hidden.js-task-list-field
+ = @snippet.description
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index 37c3e61912c..1f0e7629fb4 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -54,10 +54,10 @@
%p.light
This URL will be triggered when a merge request is created/updated/merged
%li
- = form.check_box :build_events, class: 'pull-left'
+ = form.check_box :job_events, class: 'pull-left'
.prepend-left-20
- = form.label :build_events, class: 'list-label' do
- %strong Jobs events
+ = form.label :job_events, class: 'list-label' do
+ %strong Job events
%p.light
This URL will be triggered when the job status changes
%li
diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml
index 679a5e934da..098a88c48c5 100644
--- a/app/views/snippets/notes/_actions.html.haml
+++ b/app/views/snippets/notes/_actions.html.haml
@@ -1,13 +1,10 @@
- if current_user
- if note.emoji_awardable?
- user_authored = note.user_authored?(current_user)
- = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do
+ = link_to '#', title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do
= icon('spinner spin')
%span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face')
%span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley')
%span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile')
- - if note_editable
- = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do
- = icon('pencil', class: 'link-highlight')
- = link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do
- = icon('trash-o', class: 'danger-highlight')
+
+ = render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable
diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml
index 51dbbc32cc9..216184eb839 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/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 "shared/notes/notes_with_form"
+ #notes= render "shared/notes/notes_with_form", :autocomplete => false
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index dbb9216f7d0..f246bd7a586 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -56,11 +56,11 @@
= icon('skype')
- unless @user.linkedin.blank?
.profile-link-holder.middle-dot-divider
- = link_to "https://www.linkedin.com/in/#{@user.linkedin}", title: "LinkedIn" do
+ = link_to linkedin_url(@user), title: "LinkedIn" do
= icon('linkedin-square')
- unless @user.twitter.blank?
.profile-link-holder.middle-dot-divider
- = link_to "https://twitter.com/#{@user.twitter}", title: "Twitter" do
+ = link_to twitter_url(@user), title: "Twitter" do
= icon('twitter-square')
- unless @user.website_url.blank?
.profile-link-holder.middle-dot-divider
@@ -71,7 +71,7 @@
= @user.location
- unless @user.organization.blank?
.profile-link-holder.middle-dot-divider
- = icon('building')
+ = icon('briefcase')
= @user.organization
- if @user.bio.present?
@@ -100,7 +100,7 @@
Snippets
%div{ class: container_class }
- - if @user == current_user && !show_user_callout?
+ - if @user == current_user && show_callout?('user_callout_dismissed')
= render 'shared/user_callout'
.tab-content
#activity.tab-pane
diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb
index e17add7421f..bf009dfab0f 100644
--- a/app/workers/build_success_worker.rb
+++ b/app/workers/build_success_worker.rb
@@ -11,15 +11,6 @@ class BuildSuccessWorker
private
def create_deployment(build)
- service = CreateDeploymentService.new(
- build.project, build.user,
- environment: build.environment,
- sha: build.sha,
- ref: build.ref,
- tag: build.tag,
- options: build.options.to_h[:environment],
- variables: build.variables)
-
- service.execute(build)
+ CreateDeploymentService.new(build).execute
end
end
diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_usage_ping_worker.rb
index 2f02235b0ac..0a55aab63fd 100644
--- a/app/workers/gitlab_usage_ping_worker.rb
+++ b/app/workers/gitlab_usage_ping_worker.rb
@@ -3,29 +3,17 @@ class GitlabUsagePingWorker
include Sidekiq::Worker
include CronjobQueue
- include HTTParty
def perform
- return unless current_application_settings.usage_ping_enabled
-
# Multiple Sidekiq workers could run this. We should only do this at most once a day.
return unless try_obtain_lease
- begin
- HTTParty.post(url,
- body: Gitlab::UsageData.to_json(force_refresh: true),
- headers: { 'Content-type' => 'application/json' }
- )
- rescue HTTParty::Error => e
- Rails.logger.info "Unable to contact GitLab, Inc.: #{e}"
- end
+ SubmitUsagePingService.new.execute
end
+ private
+
def try_obtain_lease
Gitlab::ExclusiveLease.new('gitlab_usage_ping_worker:ping', timeout: LEASE_TIMEOUT).try_obtain
end
-
- def url
- 'https://version.gitlab.com/usage_data'
- end
end
diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb
index c9658b3fe17..22f67fa9e9f 100644
--- a/app/workers/irker_worker.rb
+++ b/app/workers/irker_worker.rb
@@ -142,10 +142,10 @@ class IrkerWorker
end
def files_count(commit)
- diffs = commit.raw_diffs(deltas_only: true)
+ diff_size = commit.raw_deltas.size
- files = "#{diffs.real_size} file"
- files += 's' if diffs.size > 1
+ files = "#{diff_size} file"
+ files += 's' if diff_size > 1
files
end
diff --git a/app/workers/namespaceless_project_destroy_worker.rb b/app/workers/namespaceless_project_destroy_worker.rb
new file mode 100644
index 00000000000..bfae0c77700
--- /dev/null
+++ b/app/workers/namespaceless_project_destroy_worker.rb
@@ -0,0 +1,43 @@
+# Worker to destroy projects that do not have a namespace
+#
+# It destroys everything it can without having the info about the namespace it
+# used to belong to. Projects in this state should be rare.
+# The worker will reject doing anything for projects that *do* have a
+# namespace. For those use ProjectDestroyWorker instead.
+class NamespacelessProjectDestroyWorker
+ include Sidekiq::Worker
+ include DedicatedSidekiqQueue
+
+ def self.bulk_perform_async(args_list)
+ Sidekiq::Client.push_bulk('class' => self, 'queue' => sidekiq_options['queue'], 'args' => args_list)
+ end
+
+ def perform(project_id)
+ begin
+ project = Project.unscoped.find(project_id)
+ rescue ActiveRecord::RecordNotFound
+ return
+ end
+ return unless project.namespace_id.nil? # Reject doing anything for projects that *do* have a namespace
+
+ project.team.truncate
+
+ unlink_fork(project) if project.forked?
+
+ # Override Project#remove_pages for this instance so it doesn't do anything
+ def project.remove_pages
+ end
+
+ project.destroy!
+ end
+
+ private
+
+ def unlink_fork(project)
+ merge_requests = project.forked_from_project.merge_requests.opened.from_project(project)
+
+ merge_requests.update_all(state: 'closed')
+
+ project.forked_project_link.destroy
+ end
+end
diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb
index a449a765f7b..7b485b3363c 100644
--- a/app/workers/pipeline_schedule_worker.rb
+++ b/app/workers/pipeline_schedule_worker.rb
@@ -3,12 +3,18 @@ class PipelineScheduleWorker
include CronjobQueue
def perform
- Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now).find_each do |schedule|
+ Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now)
+ .preload(:owner, :project).find_each do |schedule|
begin
+ unless schedule.runnable_by_owner?
+ schedule.deactivate!
+ next
+ end
+
Ci::CreatePipelineService.new(schedule.project,
schedule.owner,
ref: schedule.ref)
- .execute(save_on_errors: false, schedule: schedule)
+ .execute(:schedule, save_on_errors: false, schedule: schedule)
rescue => e
Rails.logger.error "#{schedule.id}: Failed to create a scheduled pipeline: #{e.message}"
ensure
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index 127d8dfbb61..c29571d3c62 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -20,13 +20,32 @@ class PostReceive
# Nothing defined here yet.
else
process_project_changes(post_received)
+ process_repository_update(post_received)
end
end
- def process_project_changes(post_received)
- post_received.changes.each do |change|
- oldrev, newrev, ref = change.strip.split(' ')
+ def process_repository_update(post_received)
+ changes = []
+ refs = Set.new
+
+ post_received.changes_refs do |oldrev, newrev, ref|
+ @user ||= post_received.identify(newrev)
+ unless @user
+ log("Triggered hook for non-existing user \"#{post_received.identifier}\"")
+ return false
+ end
+
+ changes << Gitlab::DataBuilder::Repository.single_change(oldrev, newrev, ref)
+ refs << ref
+ end
+
+ hook_data = Gitlab::DataBuilder::Repository.update(post_received.project, @user, changes, refs.to_a)
+ SystemHooksService.new.execute_hooks(hook_data, :repository_update_hooks)
+ end
+
+ def process_project_changes(post_received)
+ post_received.changes_refs do |oldrev, newrev, ref|
@user ||= post_received.identify(newrev)
unless @user
diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb
index 2f7967cf531..fe6a49976e0 100644
--- a/app/workers/process_commit_worker.rb
+++ b/app/workers/process_commit_worker.rb
@@ -17,12 +17,14 @@ class ProcessCommitWorker
project = Project.find_by(id: project_id)
return unless project
+ return if commit_exists_in_upstream?(project, commit_hash)
user = User.find_by(id: user_id)
return unless user
commit = build_commit(project, commit_hash)
+
author = commit.author || user
process_commit_message(project, commit, user, author, default)
@@ -73,4 +75,16 @@ class ProcessCommitWorker
Commit.from_hash(hash, project)
end
+
+ private
+
+ # Avoid reprocessing commits that already exist in the upstream
+ # when project is forked. This will also prevent duplicated system notes.
+ def commit_exists_in_upstream?(project, commit_hash)
+ return false unless project.forked?
+
+ upstream_project = project.forked_from_project
+ commit_id = commit_hash.with_indifferent_access[:id]
+ upstream_project.commit(commit_id).present?
+ end
end
diff --git a/app/workers/remove_old_web_hook_logs_worker.rb b/app/workers/remove_old_web_hook_logs_worker.rb
new file mode 100644
index 00000000000..555e1bb8691
--- /dev/null
+++ b/app/workers/remove_old_web_hook_logs_worker.rb
@@ -0,0 +1,10 @@
+class RemoveOldWebHookLogsWorker
+ include Sidekiq::Worker
+ include CronjobQueue
+
+ WEB_HOOK_LOG_LIFETIME = 2.days
+
+ def perform
+ WebHookLog.destroy_all(['created_at < ?', Time.now - WEB_HOOK_LOG_LIFETIME])
+ end
+end
diff --git a/app/workers/repository_check/clear_worker.rb b/app/workers/repository_check/clear_worker.rb
index 1f1b38540ee..85bc9103538 100644
--- a/app/workers/repository_check/clear_worker.rb
+++ b/app/workers/repository_check/clear_worker.rb
@@ -8,7 +8,7 @@ module RepositoryCheck
Project.select(:id).find_in_batches(batch_size: 100) do |batch|
Project.where(id: batch.map(&:id)).update_all(
last_repository_check_failed: nil,
- last_repository_check_at: nil,
+ last_repository_check_at: nil
)
end
end
diff --git a/app/workers/repository_check/single_repository_worker.rb b/app/workers/repository_check/single_repository_worker.rb
index 3d8bfc6fc6c..164586cf0b7 100644
--- a/app/workers/repository_check/single_repository_worker.rb
+++ b/app/workers/repository_check/single_repository_worker.rb
@@ -7,7 +7,7 @@ module RepositoryCheck
project = Project.find(project_id)
project.update_columns(
last_repository_check_failed: !check(project),
- last_repository_check_at: Time.now,
+ last_repository_check_at: Time.now
)
end
diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb
index efc99ec962a..a338523dc6b 100644
--- a/app/workers/repository_fork_worker.rb
+++ b/app/workers/repository_fork_worker.rb
@@ -1,4 +1,6 @@
class RepositoryForkWorker
+ ForkError = Class.new(StandardError)
+
include Sidekiq::Worker
include Gitlab::ShellAdapter
include DedicatedSidekiqQueue
@@ -8,29 +10,31 @@ class RepositoryForkWorker
source_path: source_path,
target_path: target_path)
- project = Project.find_by_id(project_id)
-
- unless project.present?
- logger.error("Project #{project_id} no longer exists!")
- return
- end
+ project = Project.find(project_id)
+ project.import_start
result = gitlab_shell.fork_repository(forked_from_repository_storage_path, source_path,
project.repository_storage_path, target_path)
- unless result
- logger.error("Unable to fork project #{project_id} for repository #{source_path} -> #{target_path}")
- project.mark_import_as_failed('The project could not be forked.')
- return
- end
+ raise ForkError, "Unable to fork project #{project_id} for repository #{source_path} -> #{target_path}" unless result
project.repository.after_import
-
- unless project.valid_repo?
- logger.error("Project #{project_id} had an invalid repository after fork")
- project.mark_import_as_failed('The forked repository is invalid.')
- return
- end
+ raise ForkError, "Project #{project_id} had an invalid repository after fork" unless project.valid_repo?
project.import_finish
+ rescue ForkError => ex
+ fail_fork(project, ex.message)
+ raise
+ rescue => ex
+ return unless project
+
+ fail_fork(project, ex.message)
+ raise ForkError, "#{ex.class} #{ex.message}"
+ end
+
+ private
+
+ def fail_fork(project, message)
+ Rails.logger.error(message)
+ project.mark_import_as_failed(message)
end
end
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index b33ba2ed7c1..625476b7e01 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -1,4 +1,6 @@
class RepositoryImportWorker
+ ImportError = Class.new(StandardError)
+
include Sidekiq::Worker
include DedicatedSidekiqQueue
@@ -10,6 +12,8 @@ class RepositoryImportWorker
@project = Project.find(project_id)
@current_user = @project.creator
+ project.import_start
+
Gitlab::Metrics.add_event(:import_repository,
import_url: @project.import_url,
path: @project.path_with_namespace)
@@ -17,13 +21,23 @@ class RepositoryImportWorker
project.update_columns(import_jid: self.jid, import_error: nil)
result = Projects::ImportService.new(project, current_user).execute
-
- if result[:status] == :error
- project.mark_import_as_failed(result[:message])
- return
- end
+ raise ImportError, result[:message] if result[:status] == :error
project.repository.after_import
project.import_finish
+ rescue ImportError => ex
+ fail_import(project, ex.message)
+ raise
+ rescue => ex
+ return unless project
+
+ fail_import(project, ex.message)
+ raise ImportError, "#{ex.class} #{ex.message}"
+ end
+
+ private
+
+ def fail_import(project, message)
+ project.mark_import_as_failed(message)
end
end
diff --git a/app/workers/system_hook_worker.rb b/app/workers/system_hook_worker.rb
deleted file mode 100644
index 55d4e7d6dab..00000000000
--- a/app/workers/system_hook_worker.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-class SystemHookWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
-
- sidekiq_options retry: 4
-
- def perform(hook_id, data, hook_name)
- SystemHook.find(hook_id).execute(data, hook_name)
- end
-end
diff --git a/app/workers/project_web_hook_worker.rb b/app/workers/web_hook_worker.rb
index d973e662ff2..ad5ddf02a12 100644
--- a/app/workers/project_web_hook_worker.rb
+++ b/app/workers/web_hook_worker.rb
@@ -1,11 +1,13 @@
-class ProjectWebHookWorker
+class WebHookWorker
include Sidekiq::Worker
include DedicatedSidekiqQueue
sidekiq_options retry: 4
def perform(hook_id, data, hook_name)
+ hook = WebHook.find(hook_id)
data = data.with_indifferent_access
- WebHook.find(hook_id).execute(data, hook_name)
+
+ WebHookService.new(hook, data, hook_name).execute
end
end